Lecture
First, we'll talk about what clean architecture is in general and get familiar with concepts such as the domain, use cases, and application layers. Then we'll discuss how this applies to the frontend and whether it's even worth the trouble.
After that, we'll design the frontend of a cookie shop following the principles of clean architecture. This shop will use React as its UI framework to show that this approach to architecture works with it too. Then we'll implement one of the user scenarios from scratch to see whether it's convenient.
There will be a bit of TypeScript in the code, but only to show how to use types and interfaces to describe entities. Everything we look at today can be used without TypeScript too; the code just won't be as expressive.
Today we'll barely touch on OOP, so this post shouldn't trigger any severe allergic reactions. We'll mention OOP only once at the end, but that won't stop us from designing the application.

Over the past few years, we've seen a whole range of ideas about system architecture. Each of them produced:
The diagram at the beginning of this article is an attempt to combine all of these ideas into a single effective scheme.
The concentric circles in the diagram represent different layers of software. In general, the further out you go, the more general the level becomes. The outer circles are mechanisms. The inner circles are policies.
The main rule that makes this architecture work is the Dependency Rule. This rule states that source code dependencies can only point inward. Nothing in an inner circle can know anything about an outer circle, and nothing in an inner circle can reference anything in an outer circle. This applies to functions, classes, variables, and so on.
Moreover, data structures used in an outer circle should not be used in an inner circle, especially if those structures are generated by a framework in the outer circle. We don't use anything from an outer circle that could affect an inner one.
Entities are defined by enterprise business rules. An entity can be an object with methods, or it can be a set of data structures and functions. It doesn't matter, as long as the entity can be used across different applications.
If, on the other hand, you're writing just a single application, then the entities are that application's business objects. They encapsulate the most general, high-level rules. They're the least likely to change when something external changes. For example, they shouldn't be affected by changes to page navigation or security rules. External changes should not affect the entities layer.
This layer implements application-specific business rules. It encapsulates and implements all of the system's use cases. These use cases orchestrate the flow of data to and from the Entities layer to carry out the business rules.
We don't expect changes in this layer to affect the Entities. We also don't expect this layer to be affected by external changes such as the database, the user interface, or the framework. This layer is isolated from such concerns.
We do, however, expect changes in how the application operates to affect the Use Cases. If there are any changes to the application's behavior, they will undoubtedly affect the code in this layer.
The software in this layer is a set of adapters that convert data from the format most convenient for the Use Cases and Entities into the format most convenient for further use, for example in a database. This is the layer that will, for instance, fully contain the MVC architecture. The Models are most likely data structures passed from the controllers to the Use Cases and then back from the Use Cases to the Views.
Likewise, in this layer data is converted from the form most convenient for the Use Cases and Entities into the form most convenient for persistent storage, for example in a database. The code inside this circle should not know anything about the database. If the database is a SQL database, then any SQL statements should not be used at this level.
The outermost layer usually consists of frameworks, databases, the UI, and so on. As a rule, not much code is written in this layer, except for the code that communicates with the inner circles.
This is the layer where all the details accumulate. The web is a detail, the database is a detail; we keep these things on the outside to reduce their impact.
No, the circles are schematic. You may decide you need more than these four. There's no rule saying you must always have exactly these four. Nevertheless, the Dependency Rule always applies. Source code dependencies always point inward. As you move inward, the level of abstraction increases. The outer circle is the level of details. The inner circle is the most general.
The lower-right part of the diagram shows an example of how we cross the boundaries of a circle. The Controllers and Views interact with the Use Cases in the next layer. Note the flow of control. It begins in the Controller, moves through the Use Case, and then finishes execution in the View. Note the source code dependencies. Each of them points inward, toward the Use Case.
We usually resolve this apparent contradiction using the Dependency Inversion Principle.
For example, suppose a Use Case needs to call the View. This call must not be direct, however, so as not to violate the Dependency Rule — an inner circle knows nothing about an outer one. So the Use Case calls an interface (shown in the diagram as an Output Port) in the inner circle, and the View in the outer circle implements it.
The same technique is used to cross all the boundaries in the architecture. We take advantage of dynamic polymorphism to arrange source code dependencies so that the flow of control conforms to the Dependency Rule.
Usually the data that crosses boundaries is just simple data structures. You can use basic structures or, if you prefer, Data Transfer Objects (DTO — one of the design patterns, used to pass data between subsystems of an application). Or the data can simply be function call arguments. Or you can pack it into a hash table or an object. What matters is that the data structures being passed are isolated when crossing boundaries. We don't want to cheat and pass an Entity or a database row. We don't want the data structures to have any dependencies that violate the Dependency Rule.
For example, many frameworks (ORMs) return data in a convenient format in response to a database query. We might call this a RowStructure. We don't want to pass this structure across a boundary. That would violate the Dependency Rule, because in that case an inner circle would gain knowledge about an outer circle.
Therefore, passing data across a boundary should always be in a format convenient for the inner circle.
Designing is fundamentally about taking things apart... in such a way that they can be put back together. ...Separating things into things that can be composed that's what design is.
— Rich Hickey. Design Composition and Performance
System design, as the epigraph says, is dividing a system in such a way that it can then be put back together. And most importantly — put back together easily, without unnecessary effort.
I agree: building a little house out of blocks is easier than breaking a big boulder into pebbles. But I also consider extensibility of the system to be one of the goals of sound architecture. Program requirements change constantly. We want the program to be easy to update and adapt to new requirements. Clean architecture can help achieve this goal.
Clean architecture is a way of separating responsibilities and pieces of functionality by how close they are to the application's domain.
By domain, we mean the part of the real world that we model with the program. These are the data transformations that reflect transformations in the real world. For example, if we updated a product's name, then replacing the old name with the new one is a domain transformation.
Clean architecture is often called three-layered, because the application in it is divided into layers. The original post about The Clean Architecture includes a diagram with the layers highlighted:

A diagram of the layers in clean architecture: the domain is in the center, the application layer around it, and the adapters layer on the outside
At the center is the domain layer. These are the entities and data that describe the application's domain, as well as the code for transforming that data. The domain is the core that distinguishes one application from another.
You can think of the domain as what definitely won't change if we migrate from React to Angular, or if we change some user scenario. In the case of the shop, that's products, orders, users, the cart, and the functions for updating their data.
The data structure of the domain entities and the nature of their transformations don't depend on external circumstances. External circumstances trigger domain transformations, but don't determine how they will proceed.
The function for adding a product to the cart doesn't care how exactly the product was added: by the user through a «Buy» button or automatically via a promo code. In both cases it will take the product and return an updated cart with the product added.
Around the domain is the application layer. This layer describes use cases — that is, user scenarios. They're responsible for what happens after some event occurs.
For example, the scenario «Add a product to the cart» is a use case. It describes the actions that should happen after the button is pressed. It's a kind of «orchestrator» that says:
The application layer also contains ports — specifications of how our application wants the outside world to communicate with it. Usually a port is an interface, a contract for behavior.
Ports serve as a «buffer» between our application's wishes and the realities of the outside world. Input Ports say how the application wants to be addressed from the outside. Output Ports say how the application intends to communicate with the outside world, so that it's ready for this.
We'll look at ports and their benefits in more detail later.
On the outermost layer are the adapters to external services. Adapters are needed to turn the incompatible API of external services into one that's compatible with our application's wishes.
Adapters are a great way to reduce coupling between our code and the code of third-party services. Low coupling reduces the need to change one module when others change.
Adapters are often divided into:
The user most often interacts with driving adapters. For example, handling a button press by a UI framework is the job of a driving adapter. It works with the browser API (essentially a third-party service) and converts the event into a signal our application can understand.
Driven adapters interact with the infrastructure. On the frontend, most of the infrastructure is the backend server, but sometimes we can also interact with some other services directly, for example a search engine.
Note that the further we get from the center, the more «service-like» the code's functionality becomes, and the further it is from our application's domain. This will be important later, when we decide which layer a given module belongs to.
Three-layered architecture has a dependency rule: only outer layers can depend on inner ones. This means that:

Outer layers can depend on inner ones, but not the other way around
Sometimes this rule can be bent, though it's better not to overdo it. For example, it's sometimes convenient to use some «semi-library» code in the domain, even though by the canon there shouldn't be any dependencies there. We'll look at an example of such a violation when we get to the actual code, or you can check out the repository's description — I wrote a bit about it there too.
A haphazard direction of dependencies can lead to complex and tangled code. For example, violating the dependency rule can lead to:
Now let's talk about what this separation of code gives us. It has several advantages.
All the main functionality of the application is isolated and gathered in one place — in the domain. The functionality in the domain is independent, which means it's easier to test. The fewer dependencies a module has, the less infrastructure is needed for testing, and the fewer mocks and stubs are needed.
An isolated domain is also easier to check against business expectations. This helps new developers get oriented faster with what the application is supposed to do. In addition, an isolated domain helps you find errors and inaccuracies in the «translation» from business language to programming language faster.
The application's scenarios, the use cases, are described separately. They're precisely what dictates which third-party services will be needed. We adapt the outside world to our needs, rather than the other way around — this gives more freedom in choosing third-party services. For example, we can quickly change the payment system if the current one starts charging too high a commission.
The use case code also turns out flat, testable, and extensible. We'll see this in an example later.
External services become replaceable thanks to adapters. As long as we don't change the interface for interacting with the application, it doesn't matter to us which specific external service implements that interface.
This way we create a barrier to the spread of changes: changes in someone else's code don't directly affect ours. Adapters also limit the spread of errors while the application is running.
Architecture is first and foremost a tool. Like any tool, clean architecture has costs in addition to benefits.
The main cost is time. It's needed not only for design but also for implementation, because it's always easier to call a third-party service directly than to write adapters.
Thinking through the interaction of all the system's modules in advance is also hard, because we may not know all the requirements and constraints ahead of time. When designing, you need to keep in mind how the system might change and leave room for extension.
On the whole, the canonical embodiment of clean architecture is not always convenient, and sometimes even harmful. If the project is small, then a full implementation will be overhead that raises the barrier to entry for newcomers.
You may need to make compromises during design so as not to go over budget or past deadlines. I'll show by example exactly what I mean by such compromises.
A full implementation of clean architecture can raise the barrier to entry and bend the learning curve, because any tool requires knowing how to use it.
If you over-engineer at the start of a project, it'll be harder to onboard new developers later. You have to keep this in mind and watch out for simplicity of the code.
A specifically frontend problem is that clean architecture can increase the amount of code in the final bundle. The more code we hand over to the browser, the more it has to download, parse, and interpret.
You'll have to keep an eye on the amount of code and make decisions about where to cut corners:
You can reduce the amount of time and code by cutting corners and sacrificing a bit of canonicity. I'm not a fan of radicalism in approaches at all: if it's more pragmatic (benefits > potential costs) to break a rule, I'll break it.
So, you can neglect certain aspects of clean architecture for a while without any visible problems. The minimum necessary amount of resources that's definitely worth spending on design, though, comes down to two things.
It's precisely the isolated domain layer that helps you understand what we're even designing and how it should work. With an isolated domain, it's easier for new developers to figure out the essence of the application, its entities, and the relationships between them.
Even if we skip the other layers and hack spaghetti code into production, it'll still be easier to work with and refactor with an isolated domain that isn't smeared across the codebase. Other layers can be added as needed.
The second rule you shouldn't give up is the dependency rule, or more precisely the direction of dependencies. External services should adapt to us, and never the other way around.
If you feel like you're «tweaking with a file» to make your code able to call a search API — something is wrong. Better to write an adapter before the problem metastasizes.
Now that we've talked about the theory, we can move on to practice. Let's design the architecture of a cookie shop as an example.
The shop will sell different kinds of cookies, which may differ in composition. Users will choose cookies and order them, and pay for orders in a third-party payment service.
The home page will have a showcase of cookies we can buy. We'll only be able to buy cookies if we're authenticated. The sign-in button will take us to the login page, where we can log in.

The shop's home page
After a successful login, we'll be able to put some cookies into our cart.

The cart with selected cookies
Once we've tossed some cookies into the cart, we can place an order. After payment, we get a new order in the list and a cleared cart.
Placing an order will be exactly the scenario we implement together. You'll be able to peek at the code for the other use cases in the source.
To start designing, let's first decide which entities, scenarios, and functionality in the broad sense we'll have at all. Then we'll decide which layer they should belong to.
The most important thing in the application is the domain. It's exactly where the application's main entities and their data transformations will live. I suggest starting the design with it, so as to reflect the shop's domain in code as accurately as possible.
The domain can include:
The transformation functions in the domain should depend only on the rules of the domain and nothing else. Such functions would be, for example:

A diagram of the domain entities in the inner layer
The application layer contains use cases — user scenarios. A scenario always has an actor, usually the user, an action, and a result.
We can, for example, identify:
Scenarios are usually described in terms of the domain. For example, the scenario «place an order» actually consists of several steps:
The use case function will be the code that describes this scenario.
The application layer also contains the port interfaces for communicating with the outside world.

A diagram of use cases and ports in the middle layer
In the adapters layer, we keep the adapters to external services. The job of adapters is to make the incompatible API of third-party services compatible with our wishes.
On the frontend, this is most often the UI framework and the module for making requests to the API server. In our case, among the adapters we'll identify:

A diagram of adapters split into driving and driven
Note that the more «service-like» the functionality, the further it is from the center of the diagram. The main part of the application is in the center; it's the domain that contains the business logic and carries the business value, while everything else is supporting code.
Sometimes it's hard to decide right away which layer a given module or piece of data belongs to. A small (and incomplete!) analogy with MVC can help here:
The ideas differ in the details, but conceptually they're similar, and this analogy works quite well for identifying domain and application code.
Once we've decided which entities we'll need, we can move on to defining how they behave.
So as not to get distracted later, I'll show the code structure in the project right away. For clarity, I split the code into layer folders.
The domain lives in domain/, the application layer in application/, and the adapters in services/. I'll talk about alternatives to this way of splitting the code at the end.
In the domain we'll have 4 modules:
The main actor is the user. We'll store the user's data in storage during the session. We want to type this data, so we'll create a domain user type.
The user type will contain an identifier, a name, an email, and lists of preferences and allergies.
Users will toss cookies into the cart, so let's add types for the cart and the product. The product will contain an identifier, a name, a price in kopecks, and a list of ingredients.
In the cart, we'll only keep the list of products that the user has put into it:
After a successful payment, an order is created with the specified cookies, so let's create the order entity.
The order type will contain a user identifier, a list of ordered products, a creation date and time, a status, and the total price for the entire order.
The benefit of designing the entity types is that even now we can check how well their relationship scheme matches reality:

A diagram of the relationships between entities
We can make sure that the main actor really is the user, that there's enough information in the order, whether some entity needs to be extended, and whether there will be extensibility problems in the future.
Also, already at this stage the types will help highlight errors with the compatibility of entities with one another and the direction of signals between them.
If everything matches our expectations, then we can move on to designing the domain transformations.
All sorts of things will happen to the data whose types we just designed. We'll add products to the cart, clear it, update products and user names, and so on. For all such transformations, we'll create separate functions.
For example, to determine whether a user has an allergy to some ingredient or a preference, we can write the functions hasAllergy and hasPreference:
For adding products to the cart and checking whether a product is in the cart — the functions addProduct and contains:
We'll also need to calculate the total price of a list of products — let's write the function totalPrice for this. If we want, we can add handling of various conditions to this function, such as promo codes or seasonal discounts.
So that users can place orders, we'll add the function createOrder. When called, it will return a new order assigned to the specified user.
Note that in each function we build the API so that we find it convenient to transform the data. We take an argument and return a result in whatever form we want and find convenient.
At the domain design stage, there are no external constraints yet. This lets us reflect the data transformations as close to the domain as possible. And the closer the transformations are to reality, the easier it will be to verify their operation.
You may have noticed some types that we used when describing the domain types. For example, Email, UniqueId, or DateTimeString. These are type aliases:
I usually use type aliases to get rid of primitive obsession.
I use not just string but DateTimeString, to make it clearer exactly what kind of string is being used. The closer the type is to the domain, the easier it will be to deal with errors when they appear.
These types live in the file shared-kernel.d.ts. The shared kernel is the kind of code and data that a dependency on doesn't increase coupling between modules. For more on this concept, I recommend reading “DDD, Hexagonal, Onion, Clean, CQRS, … How I put it all together”.
In practice, the shared kernel can be explained like this. We use TypeScript itself, we use its standard type library, but we don't consider this a dependency. That's because the modules that use it can know nothing about each other and remain decoupled.
Not any code can be attributed to the shared kernel. The main and most important constraint is that such code must be compatible with any part of the system. If part of the application is written in TypeScript and part in another language, then only code that can be used in both parts can go into the shared kernel. For example, entity specifications in JSON format would work; TypeScript helpers would not.
In our case, the whole application is written in TypeScript, so the type aliases over the built-in types can be attributed to the shared kernel too. Such globally available types don't increase coupling between modules and can be used in any part of the application.
We've sorted out the domain, so we can move on to the application layer. This layer contains the use cases, or user scenarios.
In the use case code, we describe the technical details of how the scenario works. What should happen to the data after a product is added to the cart or an order is placed — that's a use case.
Use cases involve interaction with the outside world, which means using external services. Interaction with the outside world is a side effect. We know that it's easier to program and debug functions and systems without side effects. And most of our domain functions are already written as pure functions. To reconcile pure transformations with interaction with the «impure» world, we can use the application layer as an impure context.
An impure context for pure transformations is a way of organizing code in which:
Using the scenario «Add a product to the cart» as an example, this would look like this:
This yields a kind of «sandwich»: side effect, pure function, side effect. The main logic is captured in the data transformation, and all communication with the world is isolated in an imperative shell.

Functional architecture: side effect, pure function, side effect
An impure context is sometimes called a functional core in an imperative shell. Mark Seemann wrote about this on his blog. This is exactly the approach we'll use when writing the use case functions.
Of all the application's scenarios, we'll pick and design the order-placement scenario. It's the most illustrative, because it's asynchronous and pulls on many third-party services. As a reminder, you can look at the other scenarios and the code of the whole application on GitHub.
Let's think about what we want to achieve in the use case in question. The user has a cart with cookies; when the user presses the place-order button:
In terms of the API and the function signature, we want to pass the user and the cart as arguments and have the function do everything else itself.
Ideally, of course, the use case should take not 2 separate arguments but a command that encapsulates all the input data within itself. But we don't want to bloat the amount of code, so we'll leave it as is.
Let's take a closer look at the stages of the use case: the creation of the order itself is a function from the domain. Everything else is external services that we want to use.
It's important to remember that it's precisely the external services that should adapt to our needs, and not the other way around. So in the application layer we'll describe not only the use case itself but also the interfaces for these external services — the ports.
Ports should first and foremost be convenient for our application. If the external services' API is incompatible with our wishes, we'll write an adapter.
Let's estimate which services exactly we'll need:

The services needed for the scenario to work
Note that right now we're talking about the interfaces of these services, not their implementation. At this stage, it's important for us to describe the required behavior, because we'll be counting on this behavior in the application layer when describing the scenario.
How exactly this behavior will be implemented doesn't matter to us yet. The main thing is that the implementation fulfills the guarantees described in the interfaces. This lets us defer the decision about which external services to use until the very last moment — that is, it makes the code minimally coupled. We'll deal with the implementation later.
Also note that we split the interfaces by feature. Everything related to payments goes into one module, everything related to the data store into another. This makes it easier to keep the functionality of different third-party services from mixing. The ISP in all its glory.
The shop is an example application, so the payment system will be extremely simple. It will have a tryPay method that takes the amount of money to be paid, and in response will send back a confirmation that everything is fine.
We won't handle errors, because error handling is a topic for an entire separate large post 😃
Yes, payment is usually processed on the server, but this is a toy example, so we'll do everything on the client. We could easily communicate with our API instead of with the payment service directly. That change, by the way, would only affect this use case; the rest of the code would remain untouched.
If something during payment doesn't go according to plan, we need to say so.
You can notify the user in different ways. With a block in the UI, by sending emails, by vibrating the phone (please don't). In general, the notification service would also be better off abstract, so that we don't have to think about the implementation of these notifications right now.
Let it take a message and somehow notify the user:
We'll save the new order in local storage.
This storage can be anything: Redux, MobX, whatever-floats-your-boat-js. The storage can be split into micro-stores for different entities or be one big one for all the application's data — this doesn't matter right now either, because these are implementation details. What matters to us is to design the interface.
I like to split storage interfaces into a separate one for each entity. A separate interface for the user data storage, a separate one for the cart, a separate one for the order storage:
In this example, I'm only showing the order storage interface; you can look at all the rest in the source.
Let's check whether we can assemble the scenario using the created interfaces and the existing domain functionality. As we described earlier, the scenario will consist of the following steps:

All the steps of the user scenario in the diagram
Let's get to the code. First we'll declare stubs for the services we're going to use. TypeScript will complain that we haven't implemented the interfaces in the corresponding variables, but for now that doesn't matter.
Now we can use the created stubs as if they were real services — access their fields, call their methods. This will be convenient when «translating» the scenario from the language of the domain into TypeScript.
Let's create the use case function orderProducts. Inside, the first thing we do is create a new order:
Then we use the created stubs to call the needed methods of each service. Here we use the fact that an interface is a guarantee, a contract for behavior. This means that in the future the stub variables will actually perform the actions we're counting on now:
Note that the use case doesn't call the third-party services directly. It counts on the behavior described in the interfaces, so as long as the interface stays the same, it doesn't matter to us which module implements it and how. This makes the modules replaceable.
We've «translated» the scenario into TypeScript. Now we need to check whether reality matches our wishes from the interfaces.
Usually it doesn't. So we adapt the outside world to our needs using adapters.
The first adapter is the UI framework or library. It connects the native browser API and the application. In the case of the order-creation use case, this is the «Place order» button and the click handler that will run the use case function.
Hooks are fashionable these days, so let's expose the use case through a hook. Inside, we'll get all the services, and as the result from the hook we'll return the use case function itself.
You could say we're using hooks as a «homemade dependency injection». First, using the hooks useNotifier, usePayment, and useOrdersStorage, we get instances of the services, and then we use the closure of the useOrderProducts function so that they're available inside the orderProducts function.
It's important to note that the use case function is still separated from the rest of the code, which is important for testing. We'll pull it out completely and make it even more testable at the end of the article, when we do a review and refactoring.
The use case uses the PaymentService interface. Let's write its implementation.
For payment, we'll use a fake API stub. Nothing forces us to write the service now; we can write it later, the main thing is to implement the specified behavior:
The fakeApi function is a timeout that fires after 450 ms, simulating a delay in the server's response. It returns whatever we pass to it as an argument.
We explicitly type the return value of usePayment. This way TypeScript checks that the function really returns an object containing all the methods declared in the interface.
Let the notifications be a simple alert. Since the code is decoupled, there will be no problem rewriting this service later either.
Let the storage be React.Context and hooks because I'm lazy. We'll create a context, pass a value into the provider, and export the provider and access to the store through hooks.
We'll write the hook for accessing the storage separately for each feature. This way we won't violate the ISP, and the stores, at least in terms of interfaces, will be atomic.
This approach will also give us the ability to configure additional optimizations for each store: selectors, memoization, and so on.
Let's now check how the user's communication with the application will happen during the scenario we created. In the diagram, we'll show what data will come in, where it goes from, and where it goes to.

A diagram of the scenario's data flows
The user interacts with the UI layer, which can only address the application through the ports. That is, we can change the UI if we want.
Scenarios are handled in the application layer, which says exactly how the external services that may be needed can interact with it. All the main logic and data are in the domain.
All the external services are hidden in the infrastructure and comply with our specifications. If we need to change the message-sending service, the only thing we'll need to fix in the code is the adapter for the new service.
This scheme makes the code replaceable, testable, and extensible to meet changing requirements.
On the whole, for getting started and an initial understanding of clean architecture, what you've read is already enough. But I'd like to draw attention to the things I simplified to make the narrative easier. This section is optional, but it will give an expanded understanding of what code structured according to clean architecture looks like «without cut corners».
I'd single out a few things that grate on the eye and that could be improved.
You may have noticed that I use a number to describe the price. This isn't a very good practice.
A number indicates only the amount, but doesn't indicate the currency, and a price without a currency makes no sense. Properly, the price should be made an object with two fields: value and currency.
This solves the problem of storing the currency and will save a lot of effort and nerves when changing or adding currencies in the shop. I didn't use this type in the examples so as not to complicate them. In real code, though, the price would look like this type.
The price value deserves a separate mention. I always keep the amount of money in the smallest fraction of the currency in circulation. For example, for the ruble it's kopecks, for the dollar it's cents.
Such a representation of the price lets you avoid thinking about division and fractional values. With money this is especially important if we want to avoid problems with floating point and fractional prices.
Code can be organized and arranged in folders not «by layer» but «by feature». One «feature» would be a slice of the pie in the diagram below. This kind of separation is even preferable, because it lets you deploy the features separately, and this is often useful.

A component is a slice of the hexagonal pie
For how to split code into such components, I recommend reading “DDD, Hexagonal, Onion, Clean, CQRS, … How I put it all together” — it explains both the benefits and the costs of splitting. And I also recommend looking at Feature Sliced, which is conceptually very similar to component-based code separation but easier to understand.
Since we've started talking about splitting into components, it's worth mentioning cross-component usage of code too. Recall the order-creation function:
This function uses totalPrice from «another component» — the product. Such usage in itself is fine, but if we want to split the code into independent features, then addressing another feature's functionality directly won't be allowed.
Ways to get around this limitation can also be found in “DDD, Hexagonal, Onion, Clean, CQRS, … How I put it all together” and Feature Sliced.
For the shared kernel I used type aliases. They're nice in that they're simple to work with: you just create a new type and reference, say, a string. But their downside is that TypeScript has no mechanism to track their usage and enforce it.
It seems like this isn't a problem: so what if someone uses string instead of DateTimeString — the code will still compile.
The problem is exactly that the code will compile even though a broader type was used (in fancy words, a precondition was weakened). This first of all makes the code more fragile, because it allows using any strings, not only strings of special quality, which can lead to errors.
And secondly, it's confusing when reading, because it creates two sources of truth. It's unclear whether you really need to use only a date there or whether any strings are fine in principle.
There's a way to make TypeScript understand that we want a specific type — using branding, branded types. Branding gives the ability to track exactly how types are used, but makes the code a bit more complex.
The next thing that grates on the eye is the creation of the date in the domain in the createOrder function:
There's a suspicion that new Date().toISOString() will be repeated quite often in the project, and we'd like to extract it into a «helper»:
...And then use it in the domain:
But we immediately recall that you can't depend on anything in the domain — so how do we handle this? Properly, createOrder should take the data for the order in an already-prepared form; the date can be passed as the last argument:
This also lets us avoid violating the dependency rule in cases where creating the date depends on libraries. If we create the date «outside» the domain function, then it will most likely be created inside the use case and passed as an argument:
This way the domain stays independent, and it will also be easier to test.
In the examples, I decided not to dwell on this for two reasons: it would distract from the point, and I see nothing wrong with depending on your own helper that uses only the language's features. Such helpers can even be attributed to the project's shared kernel, because they only reduce code duplication.
But what was really not so good about creating the date inside the createOrder function is the side effect. The problem with side effects is that they make the system less predictable than we'd like. Pure transformations in the domain — that is, ones that don't produce side effects — help deal with this.
Creating a date is a side effect, because at different times the result of calling Date.now() is different. A pure function, on the other hand, always returns the same result for the same arguments.
I've come to the conclusion that it's better to keep the domain as pure as possible. That way it's easier to test, easier to port and update, and easier to read. Side effects sharply increase the cognitive load during debugging, and the domain is not at all the place to keep complex and tangled code.
In this little example, Order includes the cart, because the cart represents only a list of products:
This may not be suitable if the cart has additional properties that have nothing to do with the order. In such cases, it's better to use data projections or intermediate DTOs.
As an option, you can use a «Product List» entity:
There's something to discuss in the use case too. Right now the orderProducts function is hard to test in isolation from React — that's bad. Ideally, it should be possible to test it with a minimal amount of effort.
The problem with the current implementation is in the hook that provides access to the use case in the UI:
In the canonical version, the use case function would be moved outside the hook, and the services would be passed to the use case through the last argument or using DI:
The hook in this case would turn into an adapter:
Then the hook code could be attributed to the adapters, and only the use case would remain in the application layer. The orderProducts function could be tested by passing mocks of the necessary services as dependencies.
For more on how to write tests for such cases and how to refactor code to make it more convenient to test, I talked about it in a workshop on testing React applications. Fair warning, it's long, but it's worth watching 😃
In that same application layer, we currently «inject» the services by hand:
But in general this can be automated and done through dependency injection. We've already looked at the simplest variant of «injection» through the last argument, but you can go further and set up automatic injection.
Specifically in this application, I figured there's not much point in setting up DI. It would distract from the point and over-complicate the code. And in the case of React and hooks, we can use them as a «container» that returns an implementation of the specified interface. Yes, it's manual work, but at least it doesn't raise the barrier to entry and is more readable for newcomers.
The example from the post is polished and deliberately simple. Obviously, life is much more surprising and tangled than this example. So I'd like to talk about common problems that can arise when working with clean architecture.
The main problem is a domain that we lack knowledge about. Imagine that the shop has a regular product, a product on sale, and a written-off product. How do you correctly describe these entities?
Should there be a «base» entity that others will extend? How exactly do you extend this entity? Should there be additional fields? Should these entities be made mutually exclusive? How should the use cases behave if there's a different product instead of a simple one? Should you reduce duplication right away?
There may be too many questions, and there may be no answers, because no one on the team or among the stakeholders knows how the system should actually behave. If there are only assumptions, then you'll have to make a decision while in analysis paralysis, which is very hard.
Specific solutions depend on the specific situation; I can only recommend a few general things.
Don't use inheritance, even if it's called «extension», even if it seems like the interface really is being inherited, even if it seems like «well, there's clearly a hierarchy here». Just wait.
Copy-paste in code isn't always evil; it's a tool. Make two almost identical entities, watch how they behave in reality, observe them. At some point you'll notice that they've either become very different, or they really do differ by just one field. Merging two similar entities into one is easier than smearing the code with conditional checks for every possible variant.
If you do have to extend something after all...
Remember covariance, contravariance, and invariance, so as not to accidentally make more work for yourself than you should.
Use the analogy with blocks and modifiers from BEM when choosing between different entities and extensions. It really helps me determine whether I'm dealing with a separate entity or a «modifier-extension» if I think about it in the context of BEM.
The second big problem is user scenarios that are connected to one another, where an event from one scenario triggers another.
The only way to deal with this that I know and that helps me is to break scenarios into smaller, atomic ones. Even if they end up being called not «user» scenarios, at least they'll be easier to compose.
In general, the problem of such scenarios is a consequence of another big problem in programming, the composition of entities. Plenty has already been written about what to do to compose entities effectively, and there's even an entire branch of mathematics for it. We won't go far into that; it's a topic for a separate post.
In this post, I've summarized and slightly expanded my talk about clean architecture on the frontend.
This isn't the gold standard, but rather a compilation of experience working in different projects, paradigms, and languages. It seems to me a convenient scheme that lets you decouple code and create independent layers, modules, and services that can not only be deployed and published separately but also carried over from project to project if needed.
We didn't touch on OOP, since architecture and OOP are orthogonal things. Yes, architecture talks about the composition of entities, but it doesn't dictate what exactly the unit of composition should be: an object or a function. You can work with this scheme in different paradigms, which is what we saw in the examples.
About OOP, I'll only say that I recently wrote a post about how to use clean architecture together with OOP. If you got interested in the topic of architecture and you're not allergic to OOP, then I recommend checking it out too. In that post, we write a generator of pictures of trees on a canvas.
To see exactly how this approach can be combined with the other cool stuff like feature slicing, hexagonal architecture, CQS, and so on, I recommend looking at DDD, Hexagonal, Onion, Clean, CQRS, … How I put it all together and the whole series of articles from that blog. Very sensible, concise, and to the point.
Comments