avatarLuís Soares

Summary

The provided content discusses domain-centric architectures, emphasizing the importance of separating the business domain from the infrastructure to ensure a more maintainable and scalable software design.

Abstract

The article "Introduction to Domain-centric Architectures" delves into the concept of domain-centric architectures, including clean and hexagonal architectures, and their role in isolating the business logic from technological concerns. It highlights the significance of entities and use cases in domain modeling and the separation of concerns by using ports and adapters to mediate interactions with external systems. The author advocates for a design approach that prioritizes the domain's essence, encapsulating business rules and logic within entities and use cases, while infrastructure concerns are handled by adapters. This separation is guided by the dependency inversion principle, ensuring that the core domain logic remains independent of external influences and volatile technologies. The article also touches on the practical aspects of implementing such architectures, including the use of request/response models for data transfer and the establishment of a composition root for dependency injection and app configuration.

Opinions

  • The author believes that a clear understanding of domain-centric architectures is crucial for software development, as it facilitates the creation of more readable, maintainable, and testable code.
  • There is a strong emphasis on the separation of business logic from infrastructure concerns, with the opinion that this separation is the key to building adaptable and technology-agnostic applications.
  • The article suggests that use cases should be the primary focus when designing an application, as they represent the user-centered operations and the core functionality of the software.
  • Entities should not be anemic; they should encapsulate domain logic and invariants, avoiding the anemic domain model antipattern.
  • Adapters, both driving and driven, are essential for insulating the domain from external changes and for providing a clear interface for interaction with the outside world.
  • The author opines that request/response models are preferable to passing multiple parameters, as they improve code readability and self-documentation.
  • The dependency rule is a fundamental guideline that should be followed to maintain the integrity of the domain model and to prevent dependencies on volatile external systems.
  • The article conveys the opinion that the composition root is a critical component for assembling the application while keeping the domain logic pure and free from infrastructure concerns.
  • The author encourages the use of domain-centric architectures as a learning path and a possible evolutionary journey for applications, suggesting that it standardizes the codebase and simplifies the mental model for developers.

CODEX

Introduction to Domain-centric Architectures (clean, hexagonal, …)

I recall struggling to understand clean and hexagonal architectures, so I decided to write my explanation by resorting to evolving models, resembling the way we learn.

Image by Rishabh Dutta via WallpaperUse Copyright-free

The hexagonal architecture (also known as ports and adapters) is a must-know architectural pattern to separate the domain from the infrastructure. The onion architecture and the clean architecture introduce entities and use cases. All of these architectures are domain-centric — they’re derived from domain-driven design (DDD). I’ll present what I believe matters from each.

The app

People and systems from the outside world interact with your app and vice-versa. This is our starting point. I’ll use the word app but that can mean many things; from a device driver to a server-side rendered UI. In domain-driven design, it usually means a bounded context. A domain-centric architecture is not about physical architecture but instead about software design. It boils down to the decoupling of the business domain from the technology, as we’ll see later.

Your app and the outside world (i.e. the I/O devices)

Separating the domain from the infrastructure

Let’s zoom into the app and split it into domain and infrastructure. You could view them as high-level software layers. That division embodies the separation of business from technology.

Splitting the app into the domain (more stable) and the infrastructure (more volatile)

➡️ The domain (short for ‘business domain’; also known as core) is the reason why your app exists. It’s the added value to the business/users/world. It’s the app’s essence and what makes it unique. It’s supposed to be independent of technologies like databases, web APIs, and frameworks. All it contains is business logic and your domain modeling, which should use a ubiquitous language (inspired by the actual domain).

The core logic, or business logic, of an application consists of the algorithms that are essential to its purpose. They implement the use cases that are the heart of the application. When you change them, you change the essence of the application. Ports-And-Adapters

Core code doesn’t directly depend on external systems, nor does it depend on code written for interacting with a specific type of external system. Advanced Web Application Architecture

➡️ The infrastructure represents the technology; it’s all that’s not business domain; it’s how the app speaks with the outer world, whether humans or machines. On one hand, it’s how the app gets delivered (e.g. GUIs, web APIs, CLIs); on the other hand, it’s how it stores and gets its data (e.g. databases, web APIs, filesystems).

A domain-centric architecture aims to protect the domain, which contains all the essential complexity (the domain complexity we are trying to model). You should build your codebase around the domain, not the data, the database, or any other technology. The domain couldn’t care less about the surrounding infrastructure. To put it simply, the infrastructure is like a shell surrounding the core — domain. Ultimately, you could swap all the infrastructure without impacting the domain.

Domain
business / core
stable code / high-level code
essential complexity
  the reason to build the app (the essence)

Infrastructure
technology / I/O
volatile code / low-level code
accidental complexity
  the supporting technical mechanisms

Even in a simple app like a console utility, it’s worth separating the algorithm (the goal of the app: the domain) from the rest (the I/O: the infrastructure). This separation is the first stage, but the most important one. Many apps only need to get this far. You get lots of benefits for a very low cost (code readability is the most obvious).

Adapters, use cases, entities

OK, we’re ready for the second maturity level. This will be a refinement of the previous stage. Let’s consider the most relevant components of the infrastructure: the adapters.

➡️ Adapters are how your app interacts with the world. They wrap the communication with an external mechanism, isolating technical details. Common examples include API gateways, controllers (e.g. REST, GUI), loggers, monitors, email/SMS services, queue handlers, and database repositories. Adapters also mediate access to unpredictable things like identifier generators and the system clock.

Adapters lie outside the domain; they’re the intermediates with the outside world.

Adapters bridge the gap between the app and the outside (they represent the outside). Since the “database is a detail” and “the web is a detail”, every interaction with them is always done through an adapter.

The adapters are part of your codebase. The remaining portion of the infrastructure is drivers, MVC frameworks, web frameworks, filesystem APIs, client libraries, and any other mechanism that bridges away to the outside. Handlers are usually coupled with frameworks and that’s fine because they are supposed to be thin. They are just translators and only contain data conversion (i.e. parsing, formatting, rendering), error handling, and validation of syntactic rules (e.g. fail if a string is parsed as an integer). Never put business logic in adapters (e.g. web handlers, UI controllers, event handlers, service gateways).

Recall that the domain embodies the domain’s business rules. This is typically called the “business logic layer”. If we zoom into it, we’ll see the use cases and the entities.

Entities are not a software layer. The diagram shows only that they don’t depend on anything.

➡️️ Use cases (also known as ‘application services’) represent user-centered operations like Checkout or View Debts; they're the reasons to build and use the app. Use cases are not CRUD actions (that would be technical); instead, they encode business-sounding interactions with the app.

To make your app’s intents clear, use cases should be first-class citizens of your app. Additionally, a use case name should start with a verb (e.g. Create User, Delete Account, Withdraw Money). It can be a query if you’re interested in its output (e.g. Get Loan) or a command if you care about its side effects (e.g. Make Loan) (command-query separation).

A use case is like an algorithm to accomplish a client-driven task (Clean Architecture for the rest of us)

Use cases should bear no dependency on the way they’re delivered to the outside world (e.g. GUI, CLI). They solely depend on entities and abstractions of adapters. Use cases should be pure, so you should isolate all side effects and nondeterministic things into adapters (e.g. updating data, network calls, and reading the system clock).

➡️ Entities are business objects as they reflect the concepts that your app manages (e.g. Account, Deal, Movie). There’s a common belief that entities don’t contain logic but that’s wrong (anemic domain model antipattern); entities have domain logic that relates directly to them.

Logic in entities is potentially reusable across use cases (Clean Architecture for the rest of us)
A use case usually deals with one or more entities. Use cases are also known as interactors since they can interact with one or more adapters to get the job done.

Use cases deal with entities, but entities don’t depend on anything else. Both use cases and entities contain business validation rules (i.e. semantic rules, also known as domain invariants). For example, “a loan’s due date can’t surpass 30 years”.

The domain contains other elements like events, value objects (e.g. Email, Point) and enums (e.g. PaymentType) and all that represents business domain concepts, but never any reference to technology, such as REST, JSON, networking, file system, ORM, SQL, MVC, frameworks, templating engines, and environment context (e.g. env vars).

Core code doesn’t need a specific environment to run in, nor does it have dependencies that are designed to run in a specific context only. Advanced Web Application Architecture

Database tables, ORM entities, mappers, DTOs (e.g. read/write models, serializers, view models, etc.), and GUI concepts are not domain entities; they should not contain business logic. Specifically, avoid ORM objects in the domain as they pollute it with technology.

Driving and driven adapters

Recall that adapters are the mediators between the outside world and the domain (i.e. core app). We can categorize them into primary and secondary:

➡️ Primary adapters are entry points of your app operated by primary actors such as users, other systems, and automated tests. They are ‘driving adapters’ since they trigger the domain code. Primary adapters include APIs, GUIs, CLIs (notice they’re all interfaces), jobs, data migrations, and other scripts. They are also known as delivery mechanisms because that’s the only way your app’s value can be delivered to its actors — every interaction with your app comes through them.

➡️ Secondary adapters connect your app to secondary actors like databases and third-party services. They are ‘driven adapters’ since the domain triggers their usage. A secondary adapter represents a view of the world from your app’s perspective. This helps to insulate your code from changes outside of your control and therefore supports teams during contract changes since they represent single places of change. Typical examples include API clients, cloud storage clients, database adapters, and the system clock, loggers, and monitors.

(Source: Hexagonal Architecture, there are always two sides to every story)
Primary adapters
Convert a concrete technology to domain 
Entry points ~ Delivery mechanisms
Interfaces provided to primary actors
'Driving' because they invoke the domain
Triggered by a primary actor

Secondary adapters
Convert domain to a concrete technology
Interact with secondary actors
'Driven' because they're triggered by the domain

Ports and the dependency rule

Recall that the “inside can’t depend on the outside”. According to the dependency inversion principle (one of the SOLID principles):

[…] stable software architectures are those that avoid depending on volatile concretions, and that favor the use of stable abstract interfaces. Clean Architecture, Chapter 11

In other words, the domain/core shouldn’t directly depend on any adapter. But wait a minute: use cases need to rely on repositories, gateways, and other adapters. Any output/secondary adapter has this problem (for input/primary ones, the dependency points inwards). The solution is an output port.

➡️ A port defines a boundary to an external party. It codifies a communication language that exposes what’s relevant to your domain, in your domain language: a port is comprised of an interface, the input and output types (DTOs), and the possible errors. Since ports are only an abstraction, your app needs concrete implementations — the adapters. Depending on the language, you may inject interface implementations, compatible lambdas, or compatible objects (duck typing).

Ports should belong to your domain; they abstract communication with the outside by preventing a hard dependency on a concrete adapter.

Here’s an example of a repository port:

interface UserRepository {
    fun findAll(): List<User>
    fun save(user: User): SaveResult
    fun delete(email: Email)

    sealed class SaveResult
    object NewUser : SaveResult()
    object UserAlreadyExists : SaveResult()
}

// ⚠️ If you only have one implementation of a port, 
// you may violate this rule by not having the interface 
// but at least you should be aware of that.

⚠️ Never expose your entities to the outside world. Entities should live inside the domain. They can be used in the adapters but if you need to externalize them (e.g. JSON), use proper serializers (also known as presenters). Otherwise, you are coupling the domain with the outside world. Also, you may be exposing sensitive information.

Beware not to confuse the flow of control with the dependency rule: the former describes how the program flows: user → device (e.g. browser) → primary adapter (e.g. web handler) → use case → secondary adapter (e.g. repository) → device (e.g. database) → and back to the user; the latter states that dependencies should point inward.

Primary/driver adapters (delivery mechanisms) and secondary/driving adapters

Request/response models

We’ve been calling the use cases and ports with entities and primitive values. However, those options can quickly become problematic as parameters pile up. If your language doesn’t have named parameters, the solution is to create a parameter object (a request model).

// ** How to pass data to use cases **

// Using User entity:
// 👎 some parameters are not known, like the id and the hashed password;
// (will you pass null? but the fields may be non-nullable!)
fun createUserUseCase(val user: User) {
   …
}  


// As separate parameters:
// ✅ enough in languages with named/defaults parameters (e.g. Kotlin, Python)
// 👎 otherwise, lots of parameters pile up; the invocation is confusing and error-prone
fun createUserUseCase(
   val: name: String, val password, /* many more */
) {
   …
}


// ✅ Creating a DTO for encapsulating all the input
fun createUserUseCase(request: CreateUserRequest) {
   …
}
data class CreateUserRequest(
   val name: String,
   val password: String,
)

➡️ Request/response models are data transfer objects that carry data to and from use cases and adapters. They play a role in code self-documentation. Some request models are ‘write models’ as they carry data downstream (e.g. to be stored); some response models are ‘read models’ as they carry data upstream (e.g. to be retrieved).

The small boxes inside the ports and the use cases represent request/response models.

Query-type use cases can define their response model to yield their output (known as a read model). It holds multiple pieces of data (e.g. in pagination, a response with the rows data optimized for reading, the page, total rows, and other metadata).

Adapters potentially need their response models as well — which you store in a port, in the domain. For example, you should have response types to represent third-party API calls. As a second example, you should never return a database-specific row type; sometimes an entity is enough but other times you need a custom read model. This prevents leaking technology into the domain.

📝 Keep request/response models in context. I usually put them as inner classes of their owner which can be a use case or a port. That way, the context helps to document them.

Composition root

How do we tie all this together? We separate the assembling of the app from the app itself. This means loading the configuration (e.g. loading env vars can only be done here), creating the dependencies, wiring them, and booting the app. All this should happen outside of the circles, at the composition root and the app shouldn’t know anything about it.

The clean architecture has nothing to say about the codebase structure, but we can still reflect it on the way we organize it. The picture shows only a possible way of doing it.

Conclusion

My favorite aspect of a domain-centric architecture is the standardization that it brings into the codebase. Following patterns helps new developers change and create use cases. It’s harder to make mistakes like adapters directly calling other adapters — you’ll quickly remember the concentric circles and the dependency rule. When creating new components, there’s hardly anything else other than adapters, use cases, and entities — all the rest should be private. This creates a concise API with a small surface area to use in tests and implementation.

More elements could have been examined (some people consider an application sublayer within the domain), but this was an introductory article; what mattered to me was to show you the set of mental models you can use as a learning path but also as a possible journey to evolving a simple app into a more complex one. I hope that you take these as takeaways:

  • your app is comprised of a domain (core) and an infrastructure (I/O). The domain maps the business (higher-level code); the infrastructure represents the technology (lower-level code).
  • libraries and frameworks should live outside the domain. The domain should be as pure as possible and make use of the ubiquitous language.
  • the dependency rule: source code dependencies can only point inwards (e.g. a use case cannot reference a concrete adapter).
  • adapters should ideally be replaceable: it makes everything more testable and reduces the buy-in into specific technologies.

Learn more

I wrote these articles as the continuation of the domain-centric series:

There are resources to learn more:

Clean Architecture
Hexagonal Architecture
Onionarchitecture
Ports And Adapters
Automated Testing
Recommended from ReadMedium