Modular Architecture for Monoliths

For teams not ready for microservices

erik dreyer
7 min readMar 27, 2022
Photo by T.H. Chia on Unsplash

Why?

Writing high quality server-side software applications that stand the test of time is a hard problem. Over time, applications become increasingly complex and suffer from a variety of problems, some of which are outlined below.

As illustrated in Figure 1, there are a bevy of concerns, across multiple dimensions that software engineers must be mindful of at all times.

High quality software addresses most, if not all, of these. A good software architecture will either solve these concerns, make it difficult to violate them, or at the very least guide the software engineer to produce high quality software.

Figure 1: Dimensions of Software Product Quality
Figure 1: Dimensions of Software Product Quality

Credit Where Credit is due

The ideas contained here are influenced by a variety of sources. A few of those stand out above the rest and deserve to be highlighted here. For further reading I highly recommend the following:

  • Get Your Hands Dirty on Clean Architecture — A great introduction to hexagonal architectures (also called “ports and adapters”) for Spring Boot applications written in Java.
  • Domain Modeling Made Functional — Although this book targets F#, the concepts apply to many other modern languages, including Kotlin. This is by far the best introductory book for functional programming and functional domain modeling that I’ve come across and I highly recommend it to everyone.
  • https://github.com/ddd-by-examples/library — This application influenced two big decisions in this project. The first being that each bounded context is contained in its own Spring context. The second idea is that persistence operations are modeled as event handlers. More on both of these below.

If I’ve missed something/someone, contact me. I’m happy to give credit.

Some Problems With Traditional Layered Architectures

Traditionally, Spring Boot applications use a layered architecture that looks something like Figure 2. Requests into the application typically plunge through the layers and out again.

Figure 2 — Typical “Pancake” layer architecture of Spring Boot apps

Nothing is inherently wrong with this, but a layered architecture has too many open flanks that allow bad habits to creep in and make the software increasingly harder to modify over time.

Here are just some of the problems that arise:

It promotes DB-driven design:

  • By its very definition, the foundation of a traditional layered architecture is the database.
  • This creates a strong coupling between the persistence layer, the domain layer, and often the web layer as well.
  • The persistence code is virtually fused into the domain code, thus it’s hard to change one without the other. That’s the opposite of being flexible and keeping options open.

It’s prone to shortcuts:

  • The only global rule is that from a certain layer, we can only access components in the same layer or a layer below.
  • if we need access to a certain component in a layer above ours, we can just push the component down a layer and we’re allowed to access it. Problem solved? Over time, the persistence layer will grow fat as we push components down through the layers.

It grows hard to test

  • A common evolution within a layered architecture is that layers are being skipped. We access the persistence layer directly from the web layer.
  • We’re implementing domain logic in the web layer, even if it’s only manipulating a single field. What if the use case expands in the future?
  • In the tests of our web layer, we not only have to mock away the domain layer, but also the persistence layer.

It hides the use cases

  • In a layered architecture it easily happens that domain logic is scattered throughout the layers.
  • A layered architecture does not impose rules on the “width” of domain services. Over time, this often leads to very broad services that serve multiple use cases.
  • A broad service often has MANY dependencies on other Spring beans from the context, at varying layers.
  • A broad service is also often used by many components in the web layer.
  • The end result is “The Big Ball of Mud”.

It makes parallel work difficult

  • Working on different use cases will cause the same service to be edited in parallel which leads to merge conflicts and potentially regressions.

The Modulith Architecture

Modulith: A modular monolith, where a module is loosely defined as a self-contained, independent, and functionally complete software bundle that covers an area of concern for the business. A module has a well defined public interface.

The remainder of this article refers to this basic application that can serve as the basis for any new application: https://github.com/edreyer/modulith

The architecture of this project, although still a Spring Boot application, takes a different approach. None of the individual ideas implemented in this project are new, but the way in which these ideas are combined into a single architecture is new.

The following concepts dovetail together in an elegant way, working in concert to provide for a powerful system architecture. Here is a high level overview of each of the major design elements of this architecture:

Kotlin

  • The use of kotlin was a strategic choice. This language provides a number of capabilities not available to Java without which this architecture would not be possible.
  • Specifically, the features of kotlin that are heavily leveraged: Coroutines, Algebraic Data Types, and support for Functional Programming.

Domain Driven Design (DDD)

  • Not limited to just a set of human processes for requirements discovery, the output of those processes translates directly into the design of the domain of the system
  • In modulith, each Bounded Context is isolated at compile time by packing them in maven artifacts.
  • In addition to this, each Bounded Context is isolated at runtime as well. Each runs in its own Spring Application Context.

Hexagonal Architecture

  • Also knows as a Ports and Adapters architecture
  • An alternative to the 3-layer pancake architecture, this inverts the dependencies so that the domain of the application sits at the center, and depends on nothing.
  • More on this below.

Algebraic Data Types (ADTs)

  • An alternative to the traditional use of Object Oriented (OO) techniques to model a domain.
  • The power of ADTs is that they allow you to move business invariants from runtime checks to the type system, the effect of which is to move runtime errors to compile time errors. A huge win.
  • They also inherently provide state machines for each domain in your system
  • These are enabled by kotlin natively, along with exhaustive pattern matching and destructuring
  • Inherently immutable, making it easier to use in multi-threaded contexts

Functional Programming (FP)

  • Follow the link for a brief outline of the benefits
  • This project introduces the concept of a Workflow. This is a generalized form of what you might call a "Use Case". There is a 1:1 relationship between each API (e.g. Controller method) and a Workflow.
  • Every Workflow is kicked off with either a Command or Query (see: CQRS), and results in an Event.
  • Workflow never throws exceptions. Instead, the result of the workflow execution is contained in a discriminated union type containing either the desired Event output, or an Error type.
  • FP concepts extend beyond just the Workflow concept, and are embodied throughout.

Asynchronous / Non-blocking

  • This project makes use of Kotlin Coroutines.
  • A given server can handle a much higher request volume because requests aren’t bound to threads, as they are in traditional servers.
  • Coroutine support is weaved throughout such that every operation that’s performed, from each web request down to each database call are all performed using non-blocking operations.
  • Coroutines are a mechanism that makes parallel programming easy, even fun. In Java, you are forced to use primitives (e.g. Thread) that are exceedingly hard to get correct.

Command Query Responsibility Segregation (CQRS)

Event Driven

  • Direct relation to DDD, which defines a system by its Events.
  • Events generated by each workflow, are published using Spring’s event system.
  • As an example, domain objects are packaged in events, and sent to the event system. Event listeners in the database Adapters translated these into DB Entities for storage. The logic that persists the system state changes is decoupled from the business logic that generated those changes. This reduces a rather large class of bugs.

modulith Architecture Diagram

In the next installment, I’ll document the major components of this architecture and the decisions that motivated those design decisions.

Figure 3 — Architecture diagram for “modulith”

--

--