A foundational project for any technology venture
A kotlin-based Spring Boot application. The project uses a hexagonal architecture, along with DDD principals to create a clean design that helps enforce good programming practice.
Modulith is essentially a microservice architecture designed and intended to be deployed as a monolith. There is an operational complexity that comes with a microservices architecture. One can argue that microservices are a solution to a people problem, rather than something that arose due to an inherent technical problem. Put simply, there is a point at which the number of people working in a monolith becomes unwieldy.
This project is intended for smaller teams that don't want to take on the operational complexities that come with microservices, but do want to benefit from the microservice advantages.
One of the primary benefits of microservices is that it forces an organization to think deeply about the business domain, and how to split it. In the parlance of DDD, each microservice typically contains a single Bounded Context which addresses a specific area of concern for the business.
Microservices contain various mechanisms to communicate with each other. In order to allow for this, patterns have emerged. In essence, each microservice defines a contract for how other services can speak to it.
In Modulith, we've created an architecture that simulates the communication by contract that separates, but relates each separate microservice (or Bounded Context). Every Bounded Context is packaged in its own maven artifact. This gives us compile time isolation, but we can go further. At runtime, each Bounded Context runs in its own Spring Application Context.
What this does is to give us an application architecture that attempts to combine the best of both monolithic and microservice architectures.
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)
This application contains influences from a large variety of sources, but there a few sources that are important to highlight as their influence played a much larger role.
If I've missed something/someone, contact me. I'm happy to give you credit.
Traditionally, Spring Boot applications use a layered architecture that looks something like this:
Spring Boot Application Layers |
---|
Web - Controllers |
Domain - Business Logic |
Persistence - Data Access Objects |
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:
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.
All 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 the design elements of this architecture:
modulith
, each Bounded Context is isolated at compile
time by packing them in maven artifacts.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
.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.Workflow
concept, and are embodied throughout.modulith
bounded context layoutuser: Name of your bounded context (in this case, "user")
domain: The core. Depends on nothing. All Business Logic. Built using ADTs
adapter: Adapters connect to outside systems
in: Input adapters "drive" the system, causing the system to act
web: Typically Spring Controllers
out: Output adapters are "driven" by the system
persistence: Typically DB Entity classes and DAOs (e.g. Spring Repositories)
application: Comprised of Ports and Workflows, these define the interface to our app.
workflows: Workflows with defined input/output types
port: Allows communication between the app core and the adapters
in: Defines Commands/Events for "driving" operations
out: Typically interfaces called by the core for "driven" operations
Name it Clean Architecture, Hexagonal Architecture or Ports and Adapters Architecture - by inverting our dependencies so that the domain code has no dependencies to the outside we can decouple our domain logic from all those persistence and UI specific problems and reduce the number of reasons to change throughout the codebase. And fewer reasons to change means better maintainability.
The domain code is free to be modelled as best fits the business problems while the persistence and UI code are free to be modelled as best fits the persistence and UI problems.
The architecture closely follows a template outlined in Get Your Hands Dirty on Clean Architecture with some notable changes, outlined here
Create a new maven module for any new bounded context
Out of the box, a User
bounded context is created for you that includes basic registration
and login capability. This module also forms a pattern for any new bounded context.
The Domain should be built using Algebraic Data Types
More on this below, but for now read these very carefully:
Enforce Architecture with ArchUnit
This project enforces that the structure of each bounded context strictly enforces the purity of the hexagonal architecture through the use of ArchUnit testing. Specifically, it ensures that classes cannot import classes from other packages that it should NOT have access to.
It's always preferable to have compile time issues rather than runtime errors. With the use of Algebraic Data Types (ADTs) we can do just that. The effect is to move your business invariants from run-time to compile-time checks. Consider the following.
// Java
class LibraryBook {
String title;
CheckoutStatus status; // Available, CheckedOut, Overdue
Date dueDate;
}
class BookService {
public LibraryBook checkoutBook(LibraryBook book) {
if (CheckoutStatus.Available != book.getStatus()) {
throw Exception();
}
book.setStatus(CheckoutStatus.CheckedOut);
// ...
}
}
Versus
sealed class LibraryBook {
class AvailableBook(val title: String) : LibraryBook()
class CheckedOutBook(val title: String, dueDate: Date) : LibraryBook()
}
class BookService {
fun checkoutBook(book: AvailableBook): CheckedOutBook {
return CheckedOutBook(book.title, getCheckoutDate())
}
}
In the first example, it's possible you can try to checkout a book in the wrong status. You can't checkout a book if it's already checkedout. Furthermore, we could have logic bugs where the returned instance is actually incorrect.
In the second example, that error simply can't happen. By moving the status into the type system, we can construct APIs that ensure we get books in the correct statuses. Not only that, but we ensure the return type is also exactly correct.
In the first example, the dueDate
property has no meaning if a book is available
and should probably be null
. Anytime null
is a possibility, you leave yourself open to
NullPointerException
.
In the 2nd example, each type of LibraryBook
only contains the state relevant for that state.
No null fields at all. No non-relevant fields ever.
In modulith
, domain objects enforce their internal consistency. That is, if you have an instance of a domain type
it is guaranteed it is in a valid state. Here's an example for a simple type:
class NonEmptyString private constructor(override val value: String)
: SimpleType<String>() {
companion object {
fun of(value: String): ValidatedNel<ValidationError, NonEmptyString> = ensure {
validate(NonEmptyString(value)) {
validate(NonEmptyString::value).isNotEmpty()
}
}
}
}
This domain type is a String that guarantees it is non-null and non-empty. No more
if (StringUtils.isBlank(string)) {...}
You can use this type to build more complex types. As with the other domain ADTs,
you use the factory method of()
to create an instance from primitive values.
If the validation succeeds, you have your instance, otherwise you get a list
of all validation errors. NonEmptyString
has a single validation, but more complex
types will have multiple validations. The error case will return all validation errors to you,
not just the first.
There are other types like this one, and you can create your own. Again, these concepts are a practical implementation of the prefer compile time to runtime errors.
As outlined above, one of the inherent problems of the traditional layered architecture is that use cases are implicit. If someone were to ask you what use cases your application supports, would you be able to tell them? Where are they? What are they?
One possible answer, and one that isn't necessarily incorrect, is that the use cases are derived from the various API endpoints your application supports. Look at all the REST API endopints (or GraphQL, etc), viola, you have your answer.
However, look just beneath the surface of the API and then where's the logic? The logic for each use
case is typically spread out across a variety of services, and even across layers. To answer the question "What does
API
Modulith
solves this by providing a mechanism to make every use case explicit. In so doing, it changes how
typical Spring services should be constructed.
Workflow
patternA workflow is a generalization of a use case. All use cases are workflows, but not all workflows are use cases. This generalization was intentional and allows for engineers to construct complex workflows from simpler workflows. Or, as an example, to use a workflow when it's a sub-process of a use case.
The base class:
interface SafeWorkflow<R: Request, E : Event> : Workflow<E> {
suspend fun invoke(request: R): Result<E>
}
Every workflow takes a Request object, typically characterized by either a Command
or Query
(see CQRS).
Every workflow results in an event object, wrapped by a Result. The invoke()
method never throws.
Thanks to Result
, the return value is a discriminated value of either an error that occurred, or an Event
type
that contains information about the result of the workflow.
We'll focus here on using a Workflow as a Use Case. When building a use case using Workflow
, you implement
a single method. We recommend you build from the BaseSafeWorkflow
class. When using that, you override the execute()
method.
The guiding principle around implementation is that a Workflow contains the "WHAT"s, not the "HOW"s. What this means is that from the Workflow, you invoke all the service methods / sub workflows required to satisy a particular use case. The workflow contains only the invocations (the WHAT), but the implementations for those methods (the HOW) exist in Services.
To wit, say you have a use case for user registration. To satisfy that use case the following must occur:
The Workflow would invoke methods to do the above, but the logic to accomplish these steps exists outside the Workflow, typically in Services.
In contrast to how Spring Services are typically built, under this architecture, methods on a service should be small and single purpose without side effects (to the extent that's possible). This takes inspiration from functional programming, where in a pure FP application, methods are referentially transparent. That is, given an input you will always get the same output, with no side effects.
Inside this architecture, you compose a Workflow by assembling small single purpose Service methods. Because service methods are small and single purpose, they are more easily tested, more easily composed. When you have a new use case that is similar to an existing workflow, you create a new Workflow instance, but combine the service methods in a slightly different way.
This helps solve the problems of ever-growing complexity of services. Service methods tend to get more complex over time. Service dependencies (via injection) tend to grow over time. Service methods tend to get more complex over time.
The Workflow pattern gives you a place to assemble Service methods like Lego's to create full a full Use Case, or just a sub-process.
To run locally, you need to connect to a MongoDB instance. The easiest way to do that is by running the following
docker run -d -p 27017:27017 --name modulith-mongo mongo:latest
To run in IntelliJ, create a Spring Boot run configuration with the main class: io.liquidsoftware.base.server.ModulithApplication
Note, you'll need to run on Java 21 to take advantage of the virtual threads.