Domain-Driven Design, Onion Architecture, and Realizing Them in Go

How to lower a devops team's cognitive load


Posted on Thu, Jan 12, 2023
Tags golang, ddd, software-architecture
golang, ddd, software-architecture
📝 This article is a translation of the original Japanese post. View original

Background

In the places where I develop and operate systems, we’ve recently been adopting domain-driven design. At the same time, systems devops has few laws you could call absolute; almost everything has upsides and downsides (= there is no silver bullet).

In this article I organize and summarize my own understanding of Domain-Driven Design and onion architecture.

Summary (TL;DR)

  1. In the devops of business-facing systems, one important factor is that “system behavior is easy to imagine from the source code”
  2. Domain-driven design isn’t tied to a specific architecture, but adopting onion architecture makes it easier for the team to understand “which code plays which role”
  3. It isn’t a silver bullet, but if you use “domain-driven design + onion architecture” well in development, you can expect to reduce the devops team’s cognitive load

On Adopting Domain-Driven Design and Onion Architecture

What Matters in Systems devops

First, one important thing in developing business-facing systems is that “system behavior is easy to imagine from the source code.” There are many other important things, but in this article I take this one up first, as it relates to design approaches and architecture.

Why does it matter that “system behavior is easy to imagine from the source code”? Because systems frequently need changes in response to business requirements, and a system whose behavior after a change is hard to imagine becomes painful to operate. Conversely, source code that will never be changed again (embedded software, for example) may look somewhat complicated, but since it won’t be modified as long as it behaves as expected, there are cases where you don’t need to worry about it much.

So how do you make “system behavior easy to imagine from the source code”? I think it comes down to “making the dependencies within the system explicit so that the system’s structure can be understood from the source code.”

For example, suppose the system structure is a tree like the following.

A system is “a state machine that holds state and returns output for a given input,” so if you can understand from the source code that the system structure looks like the figure above, imagining its behavior becomes easy.

On the other hand, suppose the system structure looks like the following (the so-called Big Ball of Mud).

In this case, when you modify part of the program, the dependencies are complex enough that imagining where the impact propagates is difficult (though it depends on the scale of the system, the developers’ skill, and the size of the team).

Domain-Driven Design

Domain-driven design is an approach to designing software with domain knowledge (the business knowledge and rules being turned into software) at its core.

Quoting from Domain Driven Design Quickly:

So how do we create software that fits the domain smoothly? The best way is to make the software a reflection of the domain. To do that, the software needs to incorporate the core concepts and elements of the domain, and accurately reproduce the relationships between them.

Why do we develop and operate software in business? In most cases, to make work more efficient (note 1).

Because domain-driven design is a design approach that turns real-world domain knowledge into software as its core, reading well-organized domain layer code makes it easy to imagine what it’s trying to do and how it behaves (note 2). If “system behavior is easy to imagine from the source code,” then modifying code when a new business flow is added, or understanding a problem when the system behaves unexpectedly, becomes easier.

There are many books and web articles on domain-driven design worth referring to, so I’ll omit the details.

  • Note 1: Not all software development fits this. For software that doesn’t aim at business efficiency — compilers, the Linux kernel, and so on — there’s no need to adopt domain-driven design
  • Note 2: As an exception, if you have to develop with no knowledge at all of the domain you’re trying to address (for example, in contract development), then even reading the domain layer code may leave behavior hard to imagine, since you lack knowledge of the domain. In such cases too, domain-driven design may not be a good fit

Onion Architecture

Domain-driven design is only a design method that puts the domain at the center; it doesn’t prescribe the system’s architecture. DDD Reference does take up Layered Architecture, and states the key point as follows.

Isolate the expression of the domain model and the business logic, and eliminate any dependency on infrastructure, user interface, or even application logic that is not business logic.

In other words, as long as you can make the domain model and business logic independent, the architecture can be anything.

In fact, there are examples of domain-driven design implemented with clean architecture, hexagonal architecture, or in functional languages.

One such architecture that I often use is Onion Architecture (note 3). Strictly speaking, it’s the architecture in the figure below, with the original onion architecture’s naming changed.

The reason I use the architecture above is that I feel it has the following benefits.

  1. Compared with the Layered Architecture described in DDD Reference, the domain layer doesn’t depend on the infrastructure layer and can stand independently
  2. Compared with Clean Architecture, it’s simpler, and if applied appropriately across multiple components it can reduce a team’s learning cost and cognitive load (like Rails’ MVC framework)

Especially when doing devops as a team, striving to implement according to a diagrammed architecture makes it easier for newly joined members to “imagine system behavior from the source code.”

Structure in Go

Having explained domain-driven design and onion architecture, let me summarize what I actually do when developing in Go.

The basic directory structure I often use looks like this. I sometimes adjust it depending on the complexity of the domain being handled.

1
2
3
4
5
6
7
8
9
repo-root
├── cmd/      : for a command line tool, where main.go lives. Basically it just calls usecase/ to run the command
├── domain/   : the domain layer. To isolate the domain model and business logic, it must not reference (depend on) infra/ or usecase/
├── go.mod
├── go.sum
├── infra/    : the infrastructure layer. Implements the interfaces defined in the domain layer
├── internal/ : gathers cross-layer concerns such as logging, tracing, metrics, and DI
├── presentation/ : the presentation layer. Used for applications with a UI. Only lets usecase/ be referenced
└── usecase/  : the use case layer. Realizes the use cases required of the application by calling into domain/

Key Points

  • Layers made explicit by directory separation. Packages inside domain must not call infra or usecase
  • Put cross-cutting system functionality that isn’t exposed externally — logging, metrics, and so on — in the internal directory. In Go, things under the internal directory cannot be referenced from other packages
    • The domain package may reference internal, but keep it to a minimum to increase the domain’s independence. In the worst case domain logic leaks into internal, or you end up depending on logic unrelated to the domain, so use it with care

Closing

To repeat, domain-driven design is one method of system design, and it doesn’t fit all system development well. What’s asked of a software engineer is to understand what you want to achieve along with the trade-offs, and to consider using domain-driven design in situations where it looks like a good fit.

References

[Ad] もし参考になれば
実践ドメイン駆動設計

Share