You get a bonus - 1 coin for daily activity. Now you have 1 coin

Clean Architecture in Practice and Its Nuances

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.

Clean Architecture in Practice and Its Nuances

Over the past few years, we've seen a whole range of ideas about system architecture. Each of them produced:

  1. Independence from the framework. The architecture doesn't depend on the existence of any particular library. This lets you use the framework as a tool, rather than cramming your system into the constraints of its limitations.
  2. Testability. Business rules can be tested without a user interface, database, web server, or any other external component.
  3. Independence from the UI. The user interface can be changed easily without changing the rest of the system. For example, a web interface can be replaced with a console one without changing the business rules.
  4. Independence from the database. You can swap Oracle or SQL Server for MongoDB, BigTable, CouchDB, or something else. Your business rules aren't tied to the database.
  5. Independence from any external service. In fact, your business rules simply know nothing about the outside world.



The diagram at the beginning of this article is an attempt to combine all of these ideas into a single effective scheme.

The Dependency Rule


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


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.

Use Cases


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.

Interface Adapters


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.

Frameworks and Drivers


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.

Only four circles?


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.

Crossing boundaries


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.

How data crosses boundaries


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.

Architecture and design

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

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:

Clean Architecture in Practice and Its Nuances

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

The domain layer

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.

The application layer

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:

  • now go to the server, send such-and-such request;
  • now perform this domain transformation;
  • and now re-render the UI using the new data.

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.

The adapters layer

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:

  • driving — those that send signals to our application;
  • driven — those that receive signals from our application.

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.

The dependency rule

Three-layered architecture has a dependency rule: only outer layers can depend on inner ones. This means that:

  • the domain must be independent;
  • the application layer can depend on the domain;
  • the outer layers can depend on anything.

Clean Architecture in Practice and Its Nuances

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:

  • Circular dependencies, where module A depends on B, B on C, and C on A. Untangling such cycles is hard.
  • Poor testability, where testing a small part requires simulating the operation of the entire system.
  • Overly high coupling and, as a result, fragile interaction between modules.

Benefits of clean architecture

Now let's talk about what this separation of code gives us. It has several advantages.

An isolated domain

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.

Independent use cases

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.

Replaceable third-party services

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.

Costs of clean architecture

Architecture is first and foremost a tool. Like any tool, clean architecture has costs in addition to benefits.

It takes time

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.

Sometimes overly verbose

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.

It raises the barrier to entry

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.

It increases the amount of 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:

  • maybe describe the use case a bit more simply;
  • maybe call domain functionality directly from an adapter, bypassing the use case;
  • maybe you'll have to set up code splitting, and so on.

How to reduce the costs

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.

Isolate the domain

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.

Follow the dependency rule

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.

Designing the application

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.

Clean Architecture in Practice and Its Nuances

The shop's home page

After a successful login, we'll be able to put some cookies into our cart.

Clean Architecture in Practice and Its Nuances

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.

Designing the domain

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 data types of each entity: user, cookie, cart, and order;
  • factories for creating each entity, or classes if we're writing in OOP;
  • and functions for transforming this data.

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 function for calculating the total cost;
  • determining a user's taste preferences;
  • determining whether a product is in the cart, and so on.

Clean Architecture in Practice and Its Nuances

A diagram of the domain entities in the inner layer

Designing the application 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:

  • the product purchase scenario;
  • payment, calling third-party payment systems;
  • interaction with products and orders: updating, viewing;
  • access to pages depending on roles.

Scenarios are usually described in terms of the domain. For example, the scenario «place an order» actually consists of several steps:

  • get the products from the cart and create a new order;
  • pay for the order;
  • notify the user if the payment didn't go through;
  • clear the cart and show the order.

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.

Clean Architecture in Practice and Its Nuances

A diagram of use cases and ports in the middle layer

Designing the adapters 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:

  • the UI framework;
  • the module for making API requests;
  • an adapter for local storage;
  • adapters and converters of API responses to the application layer.

Clean Architecture in Practice and Its Nuances

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.

Using an analogy with MVC

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 model is usually the domain entities,
  • the controllers are the domain transformations and the application layer,
  • the view is the driving adapters.

The ideas differ in the details, but conceptually they're similar, and this analogy works quite well for identifying domain and application code.

Detailing the design: the domain

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.

  Clean Architecture in Practice and Its Nuances

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.

Writing the domain entities

In the domain we'll have 4 modules:

  • product;
  • user;
  • order;
  • cart.

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.

 Clean Architecture in Practice and Its Nuances

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.

 Clean Architecture in Practice and Its Nuances

In the cart, we'll only keep the list of products that the user has put into it:

 Clean Architecture in Practice and Its Nuances

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.

 Clean Architecture in Practice and Its Nuances

Checking the relationships between entities

The benefit of designing the entity types is that even now we can check how well their relationship scheme matches reality:

Clean Architecture in Practice and Its Nuances

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.

Creating data 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:

 Clean Architecture in Practice and Its Nuances

For adding products to the cart and checking whether a product is in the cart — the functions addProduct and contains:

 Clean Architecture in Practice and Its Nuances

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.

 Clean Architecture in Practice and Its Nuances

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.

 Clean Architecture in Practice and Its Nuances

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.

Detailing the design: the Shared Kernel

You may have noticed some types that we used when describing the domain types. For example, Email, UniqueId, or DateTimeString. These are type aliases:

 Clean Architecture in Practice and Its Nuances

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.

Detailing the application layer

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.

Using an impure context for pure transformations

An impure context for pure transformations is a way of organizing code in which:

  • we first perform a side effect to get the data;
  • then perform a pure transformation on this data;
  • and afterward perform a side effect again to save or pass on the result.

Using the scenario «Add a product to the cart» as an example, this would look like this:

  • first the handler will get data about the cart's state from storage;
  • then it will call the cart update function, passing the product to add;
  • afterward it will save the updated cart to storage.

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.

Clean Architecture in Practice and Its Nuances

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.

Designing the scenario

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:

  • we want to create a new order;
  • pay for it in a third-party payment system;
  • if the payment didn't go through, tell the user about it;
  • if it did go through, save the order on the server;
  • add the order to the local data store to display it on the screen.

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.

 Clean Architecture in Practice and Its Nuances

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.

Writing the application layer ports

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:

  • a service for paying for orders;
  • for notifying the user about events and errors;
  • for saving data to local storage.

Clean Architecture in Practice and Its Nuances

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.

Declaring the payment system interface

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.

 Clean Architecture in Practice and Its Nuances

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.

Declaring the notification service interface

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:

 Clean Architecture in Practice and Its Nuances

Declaring the local storage interface

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:

 Clean Architecture in Practice and Its Nuances

In this example, I'm only showing the order storage interface; you can look at all the rest in the source.

Writing the scenario code

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:

  • validate the data;
  • create the order;
  • pay for the order;
  • notify about problems;
  • save the result.

Clean Architecture in Practice and Its Nuances

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.

 Clean Architecture in Practice and Its Nuances

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:

 Clean Architecture in Practice and Its Nuances

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:

 Clean Architecture in Practice and Its Nuances

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.

Detailing the adapters layer

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.

Connecting the UI and the use case

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.

 Clean Architecture in Practice and Its Nuances

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.

 Clean Architecture in Practice and Its Nuances

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.

Implementing the payment service

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:

 Clean Architecture in Practice and Its Nuances

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.

 Clean Architecture in Practice and Its Nuances

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.

Implementing the notification service

Let the notifications be a simple alert. Since the code is decoupled, there will be no problem rewriting this service later either.

 Clean Architecture in Practice and Its Nuances

Implementing the local storage

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.

 Clean Architecture in Practice and Its Nuances

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.

 Clean Architecture in Practice and Its Nuances

This approach will also give us the ability to configure additional optimizations for each store: selectors, memoization, and so on.

Validating the data flow scheme

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.

Clean Architecture in Practice and Its Nuances

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.

What could be improved

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.

Use an object for the price instead of a number

You may have noticed that I use a number to describe the price. This isn't a very good practice.

 Clean Architecture in Practice and Its Nuances

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.

 Clean Architecture in Practice and Its Nuances

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.

Split the code by feature, not by layer

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.

Clean Architecture in Practice and Its Nuances

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.

Pay attention to cross-component usage

Since we've started talking about splitting into components, it's worth mentioning cross-component usage of code too. Recall the order-creation function:

 Clean Architecture in Practice and Its Nuances

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.

Use Branded Types, not aliases

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.

Pay attention to a possible «dependency» in the domain

The next thing that grates on the eye is the creation of the date in the domain in the createOrder function:

  Clean Architecture in Practice and Its Nuances

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»:

 Clean Architecture in Practice and Its Nuances

...And then use it in the domain:

 Clean Architecture in Practice and Its Nuances

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:

  Clean Architecture in Practice and Its Nuances

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:

 Clean Architecture in Practice and Its Nuances

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.

Keep the domain entities and transformations pure

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.

Pay attention to the relationship between the cart and the order

In this little example, Order includes the cart, because the cart represents only a list of products:

  Clean Architecture in Practice and Its Nuances

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:

  Clean Architecture in Practice and Its Nuances

Make the use case more testable

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:

  Clean Architecture in Practice and Its Nuances

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:

 Clean Architecture in Practice and Its Nuances 

The hook in this case would turn into an adapter:

 Clean Architecture in Practice and Its Nuances

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 😃

Set up automatic dependency injection

In that same application layer, we currently «inject» the services by hand:

 Clean Architecture in Practice and Its Nuances

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.

What can be harder in real projects

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.

Branching business logic

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.

Interdependent scenarios

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.

Conclusions

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.

Following these simple rules isn't hard, and they'll save you a lot of time in the future. By separating software into layers and following the Dependency Rule you'll create a system that's testable, with all the resulting advantages. When any of the external subsystems becomes outdated, whether it's the database or the web framework, you'll be able to replace them easily.

P.S. This article was translated as preparation for a later and slightly more practically oriented article about clean architecture in Go. Interested?

See also

  • [[b8566]]
  • [[b8715]]
  • DDD
  • CQRS
  • [[b9117]]
  • Lifecycle model
  • [[b4619]]
  • [[b6216]]
  • [[b4564]]
  • [[b3131]]
  • [[b3133]]
  • [[b6183]]
  • [[b3924]]
  • [[b134]]
  • [[b9515]]
  • [[b3112]]
  • [[b3126]]
  • [[b9149]]
  • [[b9235]]
  • [[b11605]]

See also

created: 2021-11-21
updated: 2026-03-08
184



Was this answer useful?
Choose a quick rating so we can improve the next answer for you.
How satisfied are you?


Comments

To leave a comment

If you have any suggestion, idea, thanks or comment, feel free to write. We really value feedback and are glad to hear your opinion.
To reply

Lectures and tutorial on "Software and information systems development"

Terms: Software and information systems development