A testing strategy for a domain-centric architecture (e.g. hexagonal)
I’ll propose a testing strategy for the hexagonal architecture.
Hexagonal architecture
The hexagonal architecture and the clean architecture are domain-centric architectural patterns. They’re powerful and adaptable to your needs; even simple apps like command-line tools benefit from centralizing the domain (to separate the I/O from the actual algorithm).
Here’s what you need to know:
- The main principle is that the app (e.g. a bounded context) is comprised of a ‘domain’ (core) and an ‘infrastructure’ (I/O). The ‘domain’ represents the business domain (higher-level code); the ‘infrastructure’ represents the technology (lower-level code).
- The domain should make use of the ubiquitous language; it’s mostly comprised of use cases (business commands and queries), entities (business objects), value objects, and ports (domain boundaries). Entities are not ORM objects or DTOs (e.g. view models, serializers). Entities and value objects have their logic.
- The infrastructure is made up of primary/driving adapters and secondary/driven adapters. Primary adapters (i.e. entry points or delivery mechanisms) are how users and other systems interact with your app (e.g. a web handler, a CLI, a server-side UI). Secondary adapters are how your app interacts with the outside (e.g. with the clock, with an external API, with a database).
- The adapters should isolate their technology: it makes everything more replaceable and testable, reduces the buy-in into specific technologies, and insulates code changes. That’s achieved by following the dependency inversion principle. A typical example is that you can plug in-memory databases to run tests faster since the domain doesn’t know the actual adapters it’s using.

Here’s the long story:
Towards a testing strategy
It’s difficult to provide an ideal testing strategy because that depends on the nature of what you’re testing and what are your values and principles. One thing is certain: that strategy should serve the goals of automated testing. With all that as a starting point, defining a testing strategy can be a challenge. Let’s see what is possible and go from there. Within a domain-centric architecture, there’s a limited number of levels of testing, from low-level to high-level:
- use cases and entities tested in isolation;
- testing a secondary adapter against a realistic service (e.g. testing a data repository against a testing database, testing a REST client with a fake server);
- targeting a primary adapter (e.g. web), passing through the domain, and using the necessary secondary adapters (e.g. fake in-memory versions).
- the same, but against realistic services.

The practical testing pyramid shows that you should not pick just one level. Instead, you should do a combination that better serves the testing goals.
A pervasive myth
There’s a recurring myth that states that unit tests are function tests, but that is not true (or they would have called it function/class tests). Unit tests can target different unit sizes but generally, bigger is better. The smaller ones are called solitary; at the other end, there are sociable which can interact with databases or fake services under your control (although the distinction is a bit irrelevant to me, integration tests are the ones that involve other systems with their own identity).
In a nutshell, a unit can have different sizes (it’s a spectrum) and your testing strategy should consider that.
solitary unit tests
◦ faster
◦ better at testing combinations
◦ useful to isolate very specific vehavior (e.g. caching, retries)
◦ good for London-style TDD (can be deleted later)
↑
⋮
↓
sociable unit test
◦ confidence and realism (better safety net)
◦ better as documentation (document real-life usage)
◦ better as a refactoring tool (no coupling to details)
📝 In short, the default should be sociable tests,
unless there's a strong argument to isolate components in tests.A hexagonal strategy
Applying a small set of testing patterns helps to create new functionality, which is why I’m proposing a strategy with some discipline. I’ll present you the testing strategy that I use by default, which of course needs to be adapted as needed.
In a domain-centric architecture, the testing unit is a use case — a vertical slice of code passing through an entry point, the actual use case in the domain, secondary adapters, and possibly even databases. I like to do vertical tests until proven I shouldn’t. This means that in simple apps, I probably only have vertical tests that cross all the layers and exercise the app to its boundaries. In more complex apps, I compromise strategically only when:
- there are performance concerns;
- it’s awkward to set up network requests everywhere (e.g. with WireMock);
- I want to test things that are exclusively part of a component’s identity (e.g. test a retrying mechanism in an event emitter or caching in a data fetcher) that are not supposed to leak to the rest of the app;
- I need to try combinations (to prevent combinatorial explosions).
The first compromises happen at the app’s boundaries:
- take the primary adapters out of the equation — hitting the domain directly by bypassing the entry points;
- replace the secondary adapters with test doubles (e.g. by using in-memory versions of databases);
Usually, these compromises are enough but in some rare cases, I may write solitary unit tests for domain entities if they have overly complex logic; the same applies to use cases and secondary adapters (e.g. caching, retrying, request chunking). Note that I don’t do these tests by default but rather as a fallback. I don’t compromise anymore by testing lower-level components (e.g. serializers) because those are technicalities.

We save Implementation Detail Tests for parts of the code that are naturally isolated and have an internal complexity of their own. Testing of Microservices (Spotify)
But not all unit testers use solitary unit tests. Indeed when xunit testing began in the 90’s we made no attempt to go solitary unless communicating with the collaborators was awkward (such as a remote credit card verification system). Unit Test (Martin Fowler)
Testing with the driving adapters
You may be concerned that I test the domain through the app’s entry points (i.e. primary adapters like controllers, event handlers, and command-line handlers), which is fair. In the past, my opinion was that the domain was ‘sacred’ and had to be tested in isolation. Nowadays, the domain is still ‘sacred’ but I like to test it realistically, from the outside in. Let me tell you why I changed my mind.
What are the options to test the domain?
- Test the entry points and the domain separately and in isolation. Test them together only for the happy case and accept the risk that the remaining scenarios (e.g. error cases) aren’t tested realistically.
- Test the domain directly and repeat that whole set of tests with the entry points included. This way, there's a lot of repetition so refactoring gets cumbersome.
- Test the domain only through the actual entry points.
I prefer the third option, the most pragmatic. The domain is invaluable but alone it’s not worth much — it always needs a driving adapter (i.e. delivery mechanism, entry point, primary adapter) to be useful. Also, the domain is not something that you stick in a library and share, so what’s the point of testing it in isolation? Entry points such as web handlers exist solely for one reason — to provide access to the domain; so why not exercise them in that context? Refactoring is less painful because there are fewer tests and they’re less coupled with implementation details [tests as a refactoring tool]. Realism is much higher since the domain is tested close to real-life usage [tests as a safety net].
Let’s imagine a command-line canvas app. Points cannot have negative coordinates. You can have a test for the Point value object but you also need to show how your app deals with invalid points, to ensure that the user doesn’t get ugly stack traces. Why do you need two tests? Why not have just one test that covers that rule in one place? Besides, vertical tests self-document code as they describe your app as it’s used in real life [tests as documentation].
I get paid for code that works, not for tests, so my philosophy is to test as little as possible to reach a given level of confidence. (Kent Beck)
I defend this approach by default, but I compromise one level if the need arises.
Testing with the driven adapters
This would need its article but let me try to summarize it. Let’s consider the most typical driven/secondary adapters: repositories that access databases and gateways encapsulating third-party web APIs.
For database-related adapters, I use an actual database for testing purposes for repositories that deal with databases. I set up a “before all” to prepare the database schema and a “before each” routine to reset the database data. This way, I always know I have a clean and fresh database to start with. It’s much easier to have deterministic tests.
For gateway adapters, depending on the language, I see two options:
- Launch a fake server that simulates the external party (e.g. Javalin, WireMock, JSON Server, etc.);
- Use a proven library that spies the HTTP calls at the network level (e.g. vrc, ExVCR, VCR.py, MSW, which you can use to assert the recorded calls (this is only an option if I don’t get coupled to a concrete HTTP client).
I value as important the ability to set up the stubs in each test. This way, the stubs are contextual, lean, and self-explanatory. You only see what you need, where you need it.
In both cases, there’s a third option, if I don’t want to test the app to its boundaries (for example, because it’s not practical), which is to simply replace the adapter with a test double. That is the most commonly stated advantage of hexagonal architectures. To do it, I just rely on dependency injection and replace them with stubs (I don’t use mocks because I prefer state verification).
Most of my tests are vertical but I may need to compromise and test a secondary adapter in isolation. For example, it may have concurrency, caching, or retrying and those are very specific concerns to the adapter.






