Whenever I start a greenfield software project, it’s always keep occuring to me on “what” and “how” I should proceed the project and afterwards, I put it under scrutiny with “why“. Like for example,
1. What structure should I put in place so that entropy on software is maintained or much better, reduced.
2. What ilty of software I should aiming for
3. What are the repercussion of applying certain design philosophy to the individual and team as a whole. To put it simply, the socio-technical aspect.
Then years back, I found out DDD (Domain Driven Design) and several architecture (onion, hexagonal, clean) and learnt much about it. They go with different name but all of them has commonality that is putting business concern at the central point or in mechanics parlance “the center of gravity“while other non-business concern shall revolve and evolve around it. Last but not least is Explicit Architecture, an attempt to unify DDD, Hexagonal, Onion, Clean, CQRS, C4 into one.
What is DDD?
In my opinion, Domain Driven Design is nothing but a weltanschauung of building software by making the model of software in parity (at least close) to business/real world model. To put it succinctly in mathematical notation:
When to use DDD?
Use it only for business facing software which has medium to high complexity. Don’t use it for simple project, project with short lifespan, your course project, or colloquially speaking, don’t use bazookas to kill a fly!
How to apply DDD?
Doing DDD can be splitted into two steps:
1. Strategic Design (Forest)
2. Tactical Design (Trees)
One shall not do DDD by diving right through Tactical Design (creating entities, value objects, aggregates, repositories, and others). Instead you should go up above 10,000 feet that is the forest, Strategic Design and only after that, you may go down to the trees, Tactical Design.
Strategic
First thing first, figure out what domain you’re in. In DDD parlance, domain means what business you’re in i.e. selling movies, selling ridesharing. Then we further break down this domain into each individual subdomain that contribute to that domain success. Such subdomain can be categorized into three types:
1. Core
2. Support
3. Generic
The relationship between this type as described in the blue book is hiearchical. But I prefer flat, why? Because as what Heraclitus has said change is the only constant in life, meaning what used to be generic can be core, supporting can be core and vice versa. Startup pivot, established company pivot too as the market trend changed, just look at Nokia from pulp mill to rubber to mobile phones.
Core subdomains
Core subdomains are the heart of your software. They are the one that give your business competitive advantage, the one that set you apart from other competitor software, the one that you want to innovate on. This is where the business logic complexity is high and you want to put top notch talent in charge of these.
Support subdomains
Support subdomains are area where you do it differently and it does not contribute any competitive advantage but still required because it is essential for the whole domain to run. It is a solution that is so custom that you cannot buy off-the shelf from vendors, which means you should build it in-house.
Generic subdomains
Generic subdomains are area where it is a solved problem, already an industry standard, same implementation everywhere and it is usually boring. Usually you could buy this solution from vendor, integrate from open source project or some say you should develop it in house as it can be your core someday.
Hey wait a minute…
But hey I’m an engineer/developer/coder/programmer, what do I have to do with that business stuff? really?
Yea, yea I hear you. It is because you as a developer of the software should understand the business. Your job is to translate domain model to executable code and for that to happen there must be intense interaction between devs and Domain Experts. By the way to put things clear, Domain Experts are those people who understand problem to be solved inside and out in a specific domain. After all, isn’t DDD agile? and should adhere to agile manifesto principles specifically the first and the third:
1. Individuals and interactions over processes and tools
3. Customer Collaboration over contract negotiation
Okayy fair enough, but what about the technical language I use and how do I suppose to fathom business lingo? Wouldn’t it cause friction?
That’s where Ubiquitous Language come into play.
Ubiquitous Language
Ubiquitous language is an agreed standardized language to be used across the teams or organization. You shall use this even down to your code level (class, object, module, var and what have you). The aim of ubiquitous language is to reduce friction among people in a team or between teams when they communicate each other.
Example to begin with…
We gonna use example to illustrate the application of DDD, Let’s say you’re building an e-commerce website, the domain you’re in will be retail domain, that’s the business you’re in, it is the thing that business make money.
Ubiquitous language in retail domain is essential to reduce friction, for example DEs use the term Stock Keeping Unit (SKU) for identifying an item, as what they’re taught in inventory management class, but on the other hand, the DEVs use the term UUID for identifying an item. When from technical and non-technical communicate, they will clash. That’s why it is important to standardized the language, and since DDD is domain centered, the term SKU should be used instead.
It also shall be applied on code level
defmodule Item do defstruct ~w[name description price sku]a end
Then your ubiquitous language get large and you run out of words, that’s when you have to divide and conquer your domain around according to context.
Bounded Context
This is the heart of DDD, you will be splitting your domain to make it manageable.
Imagine the following scenario:
Let’s take the word “lead”, in e-commerce’s core domain “Sales” it means person that show interest in the product while in generic domain “Authorization”, it means someone who has senior position.
Another example is with the word “expiry”, in core domain “Product” it indicate remaining shelving time for perishable item while in supporting domain “payment”, it indicate validity of the credit card used by the customer.
The ambiguity of word is best solved by drawing boundary among them, forming a bounded context. Inside this context, such word will remain understandable, less ambiguous and descriptive on the problem that the team gonna solve.
In elixir, you could implement this bounded context as a standalone elixir application and treat them as your microservice or wrap them around the process level abstraction with supervision.
Context Map
When you have quite a number of bounded contexts, it become daunting to keep track of them, this is the time we need a map. Context map put all your bounded context in place and determining relationship among them, not all bounded context are in silos they need to communicate each other, which we can fit their communication pattern in the following setup.
Communication Pattern
Shared Kernel
When coordination is limited, communication between bounded context is done through a shared model.
Open Host Service
This is when a context going to receive from many sources, that context define a standardized protocol where those sources adhere to. Then from this protocol, the context perform a translation to its context model.
Customer/Supplier
When one context feed another context like a pipeline in an upstream downstream manner, we called them to have a customer/supplier relationship. Both of party agreed on the model they want to exchange towards each other.
Conformist
Customer/supplier relationship is viable when both teams are interested in the relationship and defining a contract model on how to communicate. But when the relationship goes south towards a situation where supplier no longer care on customer’s need, the power dynamics swing to the supplier who has the upper hand to determine the exchange’s model and the customer has no choice but to conform. This can lead to one bounded context’s model conforming to other bounded context’s exchange model unless a translation layer from the conformist is made.
Anti-corruption Layer
This is simply a translation layer whenever a context trying to communicate as not to corrupt the context’s model. It’s useful to have in conformist, or in practice usually it is a must must have when you interfacing with legacy application.
Show me the code!
Software Architecture and File Organization
Explicit Architecture has two principles it rely on
1. Business Logic Purity
By putting business logic and model in the center, this layer shall be as pure as it could get. It shall not depend on other dependencies.
2. Flexibility
By means of Port (contract) and Adapter (implementation), it enable the business logic and application logic to follow along the technology cycle. One could plug in any fancy technology of the day for experimentation or plug out any fad technology.
Broadly speaking, it has four layers namely
1. Domain/Business
2. Application
which two of these will form a hexagonal shape
and then
3. Boundary
4. External World
or
Alternatively, It can be further simplified into three concerns
Internal concern
Internal concern constitute Application, Domain/Business layer
Boundary concern
Boundary cover your port, your translation layer, your model mapper, anything that acts as a gateway to your internal
External concern
External world is the dependencies, technology that you plug into your hexagon. It is also can be further divided into the drive and the driven.
1. Domain/Business layer
This layer contain the business entity and business logic. It should be as pure as it can be, meaning it should be without any dependencies (ideally). You could make it deterministic by turning it into deterministic finite-state mahine (FSM).
Some variants may include domain/business services layer where a business logic involving multiple domain/business entity.
2. Application layer
This layer contain the application logic and application model. Sometimes called as the orchestration layer because on this layer, you usually glue every other parts together, coordinating a series of task, translating from the drive into a series of task such calling into the domain to check the business logic constraint, which then you pass into the persistence (infrastructure) through repository in the boundary and probably after it turned to be okay, you trigger notifications i.e email, SMS, push notif for mobile by calling your boundary.
3. Boundary layer
Boundary layer act as a gateway to the external world, you can draw an analogy to real life thing like Ministry of Foreign Affairs, Embassy and its Ambassador. Other name for boundary layer is interfaces, contract declaration layer, translation layer, DTO mapper.
4. External world layer
External world are outside the system that wish to interact with the internal, but can only do so by adhering to the boundary layer. It usually covers infrastructures (driven) and client’s interface (drive)
Let’s say we are building an E-commerce software for a fledgling E-commerce startup called “Akazon”. The folder structure will look like this
akazon/ config/ lib/ internal/ akazon/ core/ generic/ supporting/ library/ helpers/ utils/ protocols/ boundary/ mapper/ repository/ external/ client/ (drive) console/ web/ infrastructure/ (driven) email/ persistence/ sms/ priv/ repo/ migrations/ schema/ test/
Since the startup is small, weakly funded, it should find it’s niche by conquering and winning its beachhead market, let’s say the beachhead market is selling book, then it means we gonna model book business into our sofware, which also imply that we should put that model into the core folder.
akazon/ core/ book_selling/ application/ default_impl.ex book.ex business/ book.ex book.ex impl.ex
# internal/akazon/core/book_selling/business/book.ex defmodule Akazon.BookSelling.Business.Book do defstruct ~w[title author isbn edition]a @type t() :: %__MODULE__{ title: String.t(), author: String.t(), isbn: String.t(), edition: String.t() year: integer() } end
# internal/akazon/core/book_selling/application/book.ex defmodule Akazon.BookSelling.Application.Book do use Ecto.Schema @type t() :: %__MODULE__{ title: String.t(), author: String.t(), isbn: String.t(), edition: String.t() year: integer() } @primary_key false embedded_schema do field(:id, :id) field(:title, :string) field(:author, :string) field(:isbn, :string, redact: true) field(:edition, :string) field(:year, :integer) end end
# internal/akazon/core/book_selling/application/default_impl.ex defmodule Akazon.BookSelling.Application.DefaultImpl do @behaviour Akazon.BookSelling.Application.Impl end
# internal/akazon/core/book_selling/application/impl.ex defmodule Akazon.BookSelling.Impl do end
# internal/akazon/core/book_selling/book_selling.ex defmodule Akazon.Bookselling do defp current_impl do Application.get_env(:akazon, :impl, DefaultImpl) end end
Let’s say it has been decided, the founders want to start building Akazon’s book collection, so we expect code change will be applied at application layer.
# internal/akazon/core/book_selling/application/impl.ex defmodule Akazon.BookSelling.Impl do alias Akazon.BookSelling.Application.Book @callback add_new_book_into_akazon_collection(Book.t()) :: {:ok, Book.t()} | {:error, :String.t()} end
# internal/akazon/core/book_selling/application/default_impl.ex defmodule Akazon.BookSelling.Application.DefaultImpl do @behaviour Akazon.BookSelling.Application.Impl alias Akazon.BookSelling.Application.Book @impl true @spec add_new_book_into_akazon_collection(Book.t()) :: {:ok, Book.t()} | {:error, :String.t()} def add_new_book_into_akazon_collection(book) do with {:ok, _} <- validate_when_register(book) do book |> BookStore.create_book() |> case do {:ok, res} -> {:ok, %Book{id: res.insert_book.id}} {:error, :create_failed} -> {:error, create_failed} else {:error, _} -> {:error, :validation_failed} end @spec validate_when_add_book_to_collection(Book.t()) :: Changeset.t() defp validate_when_add_book_to_collection(Book) do %Akazon.BookSelling.Application.Book{} |> change(Map.from_struct(book)) |> validate_required([:title, :author, :isbn, :edition, :year]) |> apply_action(:validate) end end
# internal/akazon/core/book_selling/book_selling.ex defmodule Akazon.Bookselling do @spec add_new_book_into_akazon_collection(Book.t()) :: {:ok, Book.t()} | {:error, :String.t()} def add_new_book_into_akazon_collection(book) do current_impl().add_new_book_into_akazon_collection(book) end defp current_impl do Application.get_env(:akazon, :impl, DefaultImpl) end end
We’re done on internal, now we move into boundary
boundary/ mapper/ repository/ book_store/ book_store.ex impl.ex
# boundary/repository/book_store/book_store.ex defmodule Akazon.Boundary.Repository.BookStore do @behaviour Akazon.Boundary.Repository.BookStore.Impl @spec create_book(BookSelling.Application.Book.t()) :: {:ok, any()} | {:error, :book_is_not_unique} | {:error, :create_failed} def create_book(book) do book |> impl().create_book() end defp impl do Application.get_env( :akazon, :book_store, Infrastructure.Persistence.MariaDB.MariaDBImpl.Book ) end end
# boundary/repository/book_store/impl.ex defmodule Akazon.Boundary.Repository.BookStore.Impl do alias Akazon.BookSelling @callback create_book(BookSelling.Application.Book.t()) :: {:ok, any()} | {:error, :book_is_not_unique} | {:error, :create_failed} end
Then we move into external side, the driven one
# external/infrastructure/persistence/repo.ex defmodule Infrastructure.Persistence.Repo do use Ecto.Repo, otp_app: :akazon, adapter: Ecto.Adapters.MyXQL end
# external/infrastructure/persistence/schema/book.ex defmodule Infrastructure.Persistence.Schema.Book do use Ecto.Schema import Ecto.Changeset @type t :: %__MODULE__{ __meta__: Ecto.Schema.Metadata.t(), id: integer | nil, title: String.t() | nil, author: String.t() | nil, isbn: String.t() | nil, edition: String.t() | nil, year: integer() | nil inserted_at: DateTime.t() | nil, updated_at: DateTime.t() | nil, deleted_at: DateTime.t() | nil } schema "books" do field(:title, :string) field(:author, :string) field(:isbn, :string) field(:edition, :string) field(:deleted_at, :utc_datetime_usec) timestamps(type: :utc_datetime_usec) end def create_changeset(book, attrs) do book |> cast(attrs, [:title, :author, :isbn, :edition]) end end
# external/infrastructure/persistence/mariadb/mariadb_book.ex defmodule Infrastructure.Persistence.MariaDB.MariaDBImpl.Book do alias Infrastructure.Persistence.Repo alias Infrastructure.Persistence.Schema @behaviour Akazon.Boundary.Repository.BookStore.Impl @impl true @spec create_book(BookSelling.Application.Book.t()) :: {:ok, any()} | {:error, :isbn_is_not_unique} | {:error, :create_failed} def create_book(book) do book_schema = book |> (&struct!(Schema.Book, &1)).() case Repo.insert book_schema do {:ok, res} -> {:ok, res} {:error, changeset} -> [hd | _] = changeset.errors case hd do {:not_unique} -> {:error, :isbn_is_not_unique} _ -> {:error, :create_failed} end end
That’s quite a lot. We start from the inside move to the outside. We touch application layer, boundary, and finally the infrastructure layer, to persist data to the database. We haven’t had a change on the business layer yet, so that’s what we gonna do next.
Let’s continue the story, after some time an angel investor come in and interested in Akazon, he had a same vision with the founders but disagree with beachhead target. He think Akazon should target the antique vintage book market, should the market won, they could move into larger market. The founder agreed with the idea and after the money invested, they started to make a little business pivot that is, become a market leader in antique books. So one of the business rule in antique book business, the book should be at least 100 years old. The business logic has changed. We gonna make code change to accomodate the business need.
We gonna touch the business layer of book and add business rule that is, book should be at least 100 years old to be considered antique
# internal/akazon/core/book_selling/business/book.ex defmodule Akazon.BookSelling.Business.Book do defstruct ~w[title author isbn edition]a @type t() :: %__MODULE__{ title: String.t(), author: String.t(), isbn: String.t(), edition: String.t() year: integer() } def from_map(fields), do: struct!(__MODULE__, fields) def is_antique?(book) do case 100 <= (DateTime.utc_now.year-book.year) do true -> true false -> false end end end
You should also make some changes in the orchestration layer a.k.a application layer
# internal/akazon/core/book_selling/application/default_impl.ex def add_new_book_into_akazon_collection(book) do is_business_valid? = book |> Map.from_struct() |> Map.drop([:id]) |> BookSelling.Business.Book.from_map() |> is_antique() with {:ok, _} <- validate_when_register(book) true <- is_business_valid? do book |> BookStore.create_book() |> case do {:ok, res} -> {:ok, %Book{id: res.insert_book.id}} {:error, :create_failed} -> {:error, create_failed} else {:error, _} -> {:error, :validation_failed} end
If you ever ask what’s that impl things and why complicate to do such. Well, it was inspired by Aaron Renner’s application layering , it is elixir’s way of doing interfaces/contract separation, it increases modularity, it also good for mock testing where we could just switch default impl with mock impl, also it is good for separating implementation which means we could switch the external part i.e Let’s say in the very far future, mariaDB become a fad abandoned project, we could switch away by just changing the impl without changing the application and business layer. Lastly, it really help you in testing your app, you can test each layer independently and very fast like in milliseconds ?!?!
Summary
Let’s make it brief, so that it can fit into your mind.
- Pick your ility
- Consider picking DDD if you build business facing software
- Enact a paradigm shift to DDD
- Strategic design (forest before trees)
- Tactical design
- Pick architectural style
- Do it in your language’s idiomatic