The domain (core)
The start of both architectures is the same: the domain. The ports and adapters architecture is often referred to as an onion structure. The reason for this is that when the application is growing you’ll see a set of layers being added on top of each other, like onion peel. The domain is always the core, the layers on top of that depend on your application. The outside layer is the outside world: this could be writing to the database, receiving an API request, or reading a file on disk – anything that is not under your control should be considered the ‘outside world’ or ‘untrusted’. You’ll notice the core of your union, the place within your control, is fairly small. In functional programming, this domain is usually pure. This is a big difference from the object-oriented approach where you inject ‘impure’ or ‘untrusted’ services within core functionality. The data direction is a lot simpler and descriptive in a port and adapter architecture. The direction and relation between the layers are made explicit while in a dependency injection system. This relation is often scattered.
The following technologies are used in this post:
In this example, I’ll use a restaurant reservation system as our core domain. This simple restaurant can reserve a table and alter the quantity of the reservations. The restaurant has a maximum capacity on any given day and can only reserve tables within the coming two weeks.
First things first, let’s start with the domain model itself.
Notice that to be completely pure, we would have to inject the ‘now’ of the reservation date and the maximum restaurant capacity. For simplicity reasons, these are left out.
💡 To be European-friendly, we could consider using a European character regular expression pattern.
This core part of the architecture shows you that the layers act as Bounded Contexts. A layer of trust separates each environment from the next. In the core domain, we work with days, not hours or minutes. We also work with a non-zero capacity and quantity. This will be different in the other layers.
To be complete, here is the rest of the domain that describes the reservation functionality and the reservation quantity alteration. This is not the main goal of this post, so I’ll leave it for you to review the functionality. Basically, we describe a strict way of reserving a table and altering the seat count of the reservation. Any failures along the way are described in custom errors.
Notice the primitive operator expressions: they are all within the domain. Take the
checkCapacity function, for example: while the addition operator `+` is happening outside the domain, this function is only using it within the bounds of the operation and does not send back any untrusted integer result.
API & database storage (ports)
Now let’s take a step back and look at the outside world, or ‘ports’ in the union architecture. The term ‘ports’ is very well chosen, I think, because it really explains what it does: a port to enter the ‘regulated city’, aka the domain. Two kinds of ports are used in this example: one for interacting with API requests and one for interacting with a database (in-memory). Both systems are outside our control, and both systems have a different way of defining their models in their bounded context.
I’ll be using Giraffe here as it’s a functional approach to web applications. This doesn’t really affect our API port here, though.
Here’s the imaginary model of our API:
Giraffe will do the serialization for us, so we only have to define the model. I explicitly use a different structure and naming to show you how dirty port models can still result in a clean domain.
The database has a very simplistic model to save reservations.
Now we finally have all the pieces together to build up our union. The API request will be transferred to domain values, the domain will run its functionality within the domain, and the result will be transferred back.
See how the adapter functions are used at every place where you go from an externalized system to the core domain, or the way around. Due to the structure of functional languages (the combinators), this is very descriptive. It explicitly flows from one layer to the next.
Hey, where are my lenses?
As a bonus, we can introduce lenses to the application. Personally, I think this greatly improves the domain because it shows how it can be used and where the accessibility stops.
If records are kept private and the set functionality is exposed as lenses, then we can compose complex domains the same way we compose functions – all the while guarding our models against corrupt states. This sample is too small to see the benefit immediately: for that, I kindly recommend reading my other post on introducing lenses in domain contexts in order to understand that it is a good habit to introduce lenses early on.
This post handled a lot of different practices. As this is a post on architecture and composability, it is important that all the pieces of the puzzle are laid out. I’ll add some links to other posts where each practice is explained in more depth.
- Integrating Arcus API Security Filters within F# Giraffe Function Pipelines
- From Untrusted Input to Strict Model with Layered JSON Parsing in F# FPrimitive & C#
- Advanced and Realistic Domain Model Validation Building Blocks in F# FPrimitive with C# Interop
- Practically Applying F# Lenses in a Domain Model Context
Onion architectures seem like a great way of keeping your domain model clean and still being able to interact with outside systems. F# is a great choice in using this architecture as it already works in the same streamlined structure that the union structure requires. Of course, this way of working can be used in object-oriented systems but I imagine that you’ll be seeing a lot of single-method interfaces which are in fact just functions hiding as objects.
I hope you consider the ports & adapters architecture/union structure in your next project.
Thanks for reading!
Subscribe to our RSS feed