Simplify & Succeed: Replacing Layered Architectures with an Imperative Shell and Functional Core

Streamline Testing by Eliminating Mocks and Focusing on Pure Functions

Simplify & Succeed: Replacing Layered Architectures with an Imperative Shell and Functional Core
Image Credit: Rawpixel

The choice of software architecture can have a profound effect on how maintainable, testable and traceable a system becomes. Two widely discussed patterns, Hexagonal Architecture (HA) and Clean Architecture (CA), are often combined with Domain-Driven Design (DDD) and Rich Domain Models to isolate business logic from technical details. However, my findings and practical experience over the last few decades in a wide variety of projects indicate that these combinations can unintentionally introduce functional dependencies into the domain, making testing and maintenance more difficult. An alternative, Pure Functions in a Functional Core, offers a simpler, more testable approach that still meets the core goals of DDD while avoiding many of the pitfalls of layered architectures.

In this article, we’ll explore how HA/CA with DDD and rich domain models can create unwanted complexity, and why I believe using Pure Functions in a Functional Core can lead to cleaner, more testable code.

Understanding the Comparison: HA/CA with DDD vs. Pure Functions

Hexagonal Architecture & Clean Architecture Basics

The hexagonal architecture (sometimes called ports and adapters) is designed to isolate the domain from external systems via ports (abstract interfaces) and adapters (concrete implementations). For example, in a user export feature, the domain might define an ExportUserport, and different adapters (such as ExportUserToCSV or ExportUserToPDF) implement the actual export details. Clean Architecture uses a similar concept, structuring software into concentric layers: the domain at the centre (entities and use cases), surrounded by the application layer, then the infrastructure. Both aim to keep the domain “clean”, free from infrastructure details.

Hexagonal Architecture Diagram: Ports and Adapters Isolating the Domain

Domain-Driven Design & Rich Domain Models

Domain-Driven Design (DDD) places the business domain at the centre of the software and models it using rich domain objects (entities, value objects, aggregates). Entities encapsulate both data and behaviour in a rich domain. For example, a user can contain the logic for promoteToAdmin or calculateMembershipLevel. This means that the business rules remain in the model itself and are not scattered across application services.

Where Complexity Creeps In

Despite the theoretical clarity, research and real-world experience indicate that when HA/CA is combined with DDD and rich domain models, the domain layer can become entangled with technical exceptions or error handling. For example:

  • Functional dependencies: When the domain layer calls a repository interface (UserRepository) or a port (ExportUser), it may be handling errors that are inherently technical (e.g., a database connection failure), leaking external concerns into core entities or services.
  • Increased testing overhead: Testing rich domain models that rely on external ports or repositories involves mocks and stubs. Although these interfaces are “abstract”, they still draw in logic related to side effects, making unit testing cumbersome.

Over time, these hidden dependencies can make the domain harder to maintain and reason about. Instead of focusing on pure business logic, developers juggle partial technical concerns in the domain or application layer, moving away from the original promise of a “pure domain”.

Functional Core/ Imperative Shell

Functional Core/Imperative Shell approach, highlighting pure functions at the center and I/O handling at the outer layer

What Is a Functional Core?

A functional core puts all business logic into pure functions. Each function is strictly dependent on its inputs and produces outputs with no side effects. This is in contrast to a rich domain object, which might call a repository or handle I/O directly. Pure functions, by definition:

  • Have no hidden dependencies on external systems.
  • Produce consistent outputs for given inputs.
  • Don’t mutate shared state or produce side effects.

The complementary layer is the Imperative Shell, which handles I/O operations and orchestration. This shell reads data from a database, network or file, passes it to the Functional Core, and then takes the Core’s output and writes it back to the outside world. This design ensures that all domain logic remains “pure” and any side effects are safely outside the domain.

Stay updated on the latest articles covering everything from programming to architecture. By subscribing, you’ll be the first to get new content, insights, and exclusive code samples. All completely free.

Subscribe Now

Keeping the Domain Simple

Instead of implementing a user entity with methods that call repositories or handle exceptions from the file system, you’d have a function like:

calculateMembershipLevel(userData) -> newMembershipLevel

It takes a plain data structure (userData), executes the logic, and returns a result (newMembershipLevel). If the logic requires more data (such as a historical purchase record), the shell is responsible for collecting it, but never the function itself. This way, testing the domain function is as simple as passing sample data and checking the output.

User data flows into a pure function core, returning new membership levels without side effects.

Imperative Shell

The Imperative Shell manages all of the application’s side effects, such as database interactions, HTTP request handling, logging and external service calls. By placing these operations at the edge rather than in the core, the shell ensures that the pure logic in the functional core remains focused on the business rules.

Functionally, the shell acts as a translator between the outside world and the functional core. It accepts incoming data from various sources, reformats it for the pure functions of the core, then processes the output of the core and applies any necessary side effects, such as updating a database or sending a response back to a client. This clear separation keeps external complexity out of the core, making the system easier to understand, test and maintain.

Unidirectional Code/ Data Flow

A key aspect of the Functional Core/Imperative Shell approach is the way in which application workflows are composed. Rather than relying on a complex web of method calls scattered throughout the code, the Functional Core idea advocates simple function composition: each function takes the output of the previous function as its input, creating a clear, unidirectional flow of data. This approach keeps the code coherent and maintainable, especially in larger systems.

All side effects and I/O (such as database writes or network requests) live at the edge: the shell. The business logic remains in the core as pure functions that receive data, transform it and return new data, with no hidden dependencies or side effects. This separation makes the flow of data and where side effects occur crystal clear.

Unidirectional flow and self-contained transitions flow in one direction: Data travels through the shell, is passed to a pure function in the core that produces a result, and then control returns to the shell. There are no back-and-forth calls or complicated callback patterns within the domain itself.

Self-contained transitions: Each step in a workflow (pure function) performs exactly one task. Its output becomes the input to the next step, reducing dependencies and simplifying code reasoning.

HTTP Web Server Example 

Imagine a straightforward HTTP server that handles incoming requests and generates responses:

Unidirectional flow chart showcasing how HTTP requests pass through an Imperative Shell to pure functions and back, ensuring minimal dependencies and better testability.

The shell receives the HTTP request, parses the headers and extracts the query/body data. The functional core handles the business logic, such as the calculateDiscount pure function. The shell takes the result, serializes it (JSON or HTML) and sends it back over the network.

The server reads the network, the pure function in the core processes the data with no side effects, and the server (shell) writes a response. Each part is well defined, making it easier to test and debug.

The Functional Core/Imperative Shell model brings clarity and resilience to modern software projects, particularly in complex domains where controlling side effects and managing complexity can be an ongoing challenge. By focusing on unidirectional function composition, it becomes easier to track data transformations, limit technical debt, and ensure that critical business logic remains thoroughly testable and free from external concerns.

Why Pure Functions?

Fewer Hidden Dependencies

In the rich domain model approach, even if you hide infrastructure details behind interfaces, the domain often deals with exceptions or state changes that point to external concerns.

Pure functions eliminate these hidden dependencies entirely: They only accept and return data.

In my experience, “technical impurities” (e.g. database or network error handling) often creep into a rich domain model, making the logic more complex. In the rich domain model approach, even if you hide infrastructure details behind interfaces, the domain often deals with exceptions or state changes that point to external concerns. Pure functions eliminate these hidden dependencies entirely: they only accept and return data.

Easier Testing Without Mocks

Pure functions require no mocking of external dependencies because there are none. You can test them by passing input structures and comparing the result with what you expect. Unlike the hexagonal/clean architecture approach, where testing an entity’s method might require mocking a repository or a port interface, pure functions drastically reduce overhead.

Practical example: If you have a function like calculateDiscount(price, customerType) -> discount, just call it in a test. No need to set up a fake database or fake external services.

Reduced Maintenance Overhead

Rich domain models are layered with object-oriented features: inheritance, method overrides, repositories, ports, etc. Over time, these can become large and unwieldy if not meticulously managed. Pure functions remain small, composable, and stateless. When you must update or extend business logic, you modify a single function or create a new one, keeping changes isolated and explicit.

DDD Alignment: DDD focuses on capturing business rules. Pure functions still do exactly that, but now the domain logic is “pure”, with side effects moved to a separate shell.

Practical Benefits & Implementation Considerations

Where This Fits with HA/CA

As most of you will have noticed from my recent posts, I am no longer a big fan of HA/CA. This is only because in most practical implementations it is precisely these functional dependencies that are created by dependency inversion. But ironically, you can still use hexagonal or clean architecture as a structural blueprint while keeping the domain layer purely functional. The ports/adapters concept can remain at the boundary between the imperative shell and the domain. However, instead of having Rich domain objects that implement those ports or handle external exceptions, you place pure functions in the domain. The shell becomes the single place responsible for all side effects.

This approach addresses my biggest critique: that HA/CA in practice introduces functional dependencies. By adopting pure functions, the domain is truly isolated, and your domain layer stops being burdened with handling technical exceptions or I/O logic.

Testability in Practice

Once you adopt a Functional Core, tests become more about verifying:

  1. Functional correctness of each domain function (simple unit tests).
  2. Integration in the shell, ensuring side effects are invoked properly (mocking external systems only in the shell tests).

This separation often shortens feedback loops and increases developer confidence. These are two critical factors in large-scale systems.

Handling Errors

One concern with pure functions is handling complex errors. This is especially true if your domain needs to respond to certain external errors (such as “database unreachable”). In a Functional Core world, such a failure is not a domain concern but a shell concern. The shell might return an “error code” or an “option type” to the domain function if needed, but it’s still orchestrated outside the domain logic. As a result, business rules themselves remain focused on business data.

Comparative Overview

Below is a side-by-side look at how the HA/CA + Rich Domain Models compare to a Functional Core with a Pure Functions approach:

Comparison table contrasting Hexagonal/Clean Architecture plus DDD against a Functional Core with Pure Functions, focusing on core dependencies, complexity, and testability.

Final Thoughts

Hexagonal Architecture and Clean Architecture, especially when coupled with DDD and rich domain models, were intended to isolate business logic from technical details. In theory, this is admirable. In practice, these designs can create functional dependencies (such as domain objects dealing with repositories or external ports) that make the domain more complicated and less testable.

In contrast, Pure Functions, as the heart of a Functional Core, depend only on inputs, produce predictable outputs, and keep side effects in the imperative shell. This approach is proven:

  • Reduce hidden complexity
  • Simplify testing by eliminating mocks for domain logic
  • Align strongly with DDD’s emphasis on core business rules
  • Improve overall maintainability and adaptability

If you’ve been struggling to keep your HA/CA or DDD layers free from external noise, consider moving to a functional core approach. Start with a small feature slice, refactor the logic into pure functions, and push the I/O into the shell. You may find that the benefits of clarity, testability and reduced overhead are well worth the change.

Cheers!

Subscribe to Rico Fritzsche

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe