Story time

Perhaps you recognize yourself in the following anecdote?

I had just finished a project where we had successfully used Clean Architecture. It went very well! Sure, we had some struggles, but we reaped all the benefits that are typically ascribed to such architectures. The codebase was very easy to work with and remained so even after months of development. The tests were easy to write and maintain. And the business logic was very clear and expressive.

Then I started my next project. We were a small team, and the design of the back-end was mostly up to me. So naturally, I decided to use a domain-centric architecture again. But when I started building the first feature, I quickly got this gnawing feeling that something wasn’t quite right. The codebase was well structured, sure, but I felt that I was writing more boilerplate code than actual business logic. And the tests, even though testability was still excellent, weren’t as expressive as I wanted them to be. After a few days of this, I decided to start from scratch and rethink the architecture, which turned out to be an excellent decision.

Where did this friction come from? What was so different about this second project that suddenly made a domain-centric architecture a bad fit? To understand this, let’s look at the key properties of domain-centric architectures first.

Properties of domain-centric architectures

So what is a domain-centric architecture? You may have heard of Clean Architecture, Hexagonal Architecture (a.k.a. Ports and Adapters), or Onion Architecture. These are all examples of domain-centric architectures. I will assume that you have a basic familiarity with one of those. If you haven’t, check out one of these excellent resources:

  • Guy Ferreira’s video or article on Hexagonal Architecture
  • The book Clean Architecture, by Robert C. Martin
  • Alistair Cockburn’s article on Hexagonal Architecture

While these architectures all use different terminology and have slightly different emphases, they all share some common properties. One key property is that these architectures create a strict separation between the domain logic, internal to the application, and the ‘outside world’, such as databases, user interfaces, web services, etc.

Often, this separation is achieved by defining interfaces and separate objects to model the interaction with the outside world. Inputs, such as commands from a user interface or calls through a web API, are converted to operations on a rich domain model. Outputs, such as operations on a database, are again generated from the domain model.

flowchart LR In((Input)) --> B1["Boundary"] --> D((Domain)) --> B2["Boundary"] --> Out((Output))

The first boundary might be a use case in Clean Architecture terminology, or a primary adapter in Hexagonal Architecture. The second boundary might be a repository or database gateway in Clean Architecture, or a secondary adapter in Hexagonal Architecture.

What’s the issue with this?

The above setup looks pretty minimal to me. There’s not that many moving parts involved, and you achieve a nice isolation of your domain logic and excellent testability. How can this be a bad thing?

For one, consider a scenario where you have a pure CRUD application. Let’s say we’re saving some data record. If we use a domain-centric architecture, the input is just a representation of the object that you want to save. The domain model will look identical, but there won’t be any logic or operations that it will perform. And the output is, yet again, a completely identical object. The boundaries then are simply mapping to and from the domain model. And their implementations will be symmetrical, and pretty trivial.

flowchart LR In((Input)) --> B1["Boundary"] --> D((Domain)) --> B2["Boundary"] --> Out((Output)) In -. identical .- D -. identical .- Out classDef obj fill:#927C71,stroke:#333 classDef boundary fill:#253D1B,stroke:#333,color:#fff class In,D,Out obj class B1,B2 boundary

That’s a lot of redundancy and boilerplate for what could simply be an object definition, and some code that stores it.

flowchart LR Obj((Object)) --> App["App logic (e.g. saving)"]

In most cases, all this redundancy and boilerplate distracts from the purpose of the code. The advantage of domain-centric architectures is that is isolates the domain and makes it easy to work with. But in this case there is no domain to speak of. So you’re writing and maintaining a lot of code, and instead of providing clarity, it actually obscures the purpose of the code.

Another counterexample: mapping code

I want to highlight another type of application where domain-centric architectures are not a good fit: applications that are mostly about mapping data from one format to another.

Remember the anecdote at the beginning of this article? This was exactly the scenario I was running into. The application I was working on had to take user input and pass it down to an external system that would process it. My code would not contain any business decisions whatsoever. Just some basic validation at most, and then simply forward the operation.

When I started out with Clean Architecture, it looked something like this:

flowchart LR In((UI command)) B1["Use case"] M1["Mapper"] D((Domain)) B2["Gateway"] M2["Mapper"] Out((Output DTO)) subgraph b1 [Boundary] direction TB B1 M1 end subgraph b2 [Boundary] direction TB B2 M2 end In --> B1 --> D --> B2 --> Out

Add in several redundant, not very expressive unit tests, and you can see that there’s a lot of code to achieve something that could be done much simpler.

After I realized this, I decided to refactor the code to a much better fitting structure:

flowchart LR In((UI command)) subgraph App direction TB O["Orchestrator"] M["Mapper"] end Out((Output DTO)) In --> O --> Out

The orchestrator would take the input, validate it, convert it to the output format using the mapper, and pass it on to the external system. That’s all the app needs to do, and there’s no extraneous code involved.

Much better tests, too

Not only did this change make the code much simpler. I realized that I could write much more expressive tests, too. In my first attempt, I had written tests for the use case, and maybe even separate tests for the mappers. These tests were fine, from a technical point of view. But from reading the tests, you wouldn’t get a good sense of what the application was supposed to do.

Think about it: the application is supposed to take some input and pass it on to an external system in the correct format. That’s what you would want to test: given this input, does the application produce that output? That test clearly describes the intended behavior, and a green light on such a test gives a lot of confidence that the app as a whole works as intended. But from my fragmented, micro-level unit tests, that was not clear at all.

Conclusion

I would still default to a domain-centric architecture for most applications, for all the benefits they provide. But for some applications, where there isn’t really a domain to speak of, or where the application is mostly about mapping data from one format to another, a simpler architecture might be a much better fit.

What about you? Do you have a similar experience using domain-centric architectures? Have you run into different scenarios where it wasn’t a good fit?