Practical DDD — aligning generic services maturity with practical patterns

Hila Fox
Machines talk, we tech.
13 min readJul 14, 2023

When I started researching generic sub-domains, I didn’t know that this level of depth could be reached. It’s generics; we have seen this already, and inventing the wheel is unnecessary. But as I learn more about it, I have learned that while in the perfect world, we might have tools that fit our every need, in the real world, this does not happen. In the real world, we find ourselves enriching the “off the shelf” tools we buy with the missing requirements that are specific for us to give good product infrastructure to our core and supporting teams.

Architectural topic 3 — cross-cutting service

Initially, I intended to write a blog post called “Practical DDD — architecture topic 3 — cross-cutting service”. But as this topic is relatively short, I have decided to jump the gun here and share a deeper conversation and research I have done around generic sub-domains that have product influence, their relationship to product maturity, and practical architectural patterns derived from context mapping.

Start with some definitions

Disciplinary vs. Product Infrastructure

In Augury, we differentiate between product infrastructure and technical infrastructure —

  • Disciplinary infrastructure — does not require business/product context to be built and is mainly used by developers. It does not immediately impact user experience and can be contained within a specific guild.
  • Product infrastructure — impacts user experience or has product requirements.

The platform group is responsible for product infrastructure, while relevant disciplinary guilds are responsible for technical infrastructure. In this post, I will refer to generics related to product infrastructure.

This table gives some examples of different infrastructure and their mapping:

What are GBCs (Generic bounded contexts)?

In DDD, we can see three main types of sub-domains. In Augury, we decided not to use the definition of subdomains to avoid overloading terms in the company, so we chose to focus on bounded contexts only.

Reminder — A Bounded Context (BC) aggregates our product's related areas \ business requirements. As our product evolves, more BCs are born. In DDD, a BC contains one or more Aggregates. For more definitions, you can look at the Backend Architecture definitions. To read more about our basic definitions, you can check out the first post of practical DDD.

Bounded Context types

Because we avoided adding subdomains, we have defined different types of bounded contexts in our system —

  1. Core BC (The magic) — a core BC will include the business logic that gives us a competitive edge over others. This logic is Augury-specific and can’t be bought off the shelf.
  2. Supporting BC (Magic enabler with context) — a supporting BC will include Augury-only business context. We will most likely choose to implement it ourselves as it is required for the core functionality Augury offers.
  3. Generic BC (Magic enabler without context) — a generic BC does not include Augury-only business context and most likely can be bought as an off-the-shelf product. This is because those problems were already “solved” by other domain experts (companies).
  • The technical infrastructure is not mapped in our bounded context mapping in the company. For example, here we have a list of GBC we have defined in our system —

Cross-cutting services

Reminder — A Cross-Cutting concern provides commonly-used capabilities to different aggregates in the system. It is not oriented to a specific product area by design and should be able to support various aggregates to fulfill their day-to-day tasks.

A cross-cutting service is a practical pattern that supplies a specific concern’s interface.

When -> Then to create a cross-cutting micro-service:

  • When we need to provide a repeating capability with no specific domain expertise
  • When we find ourselves with many dependencies on our service without a real business domain reason
  • When we need to provide “util” capabilities with deployment independently to help other services to fulfill their day-to-day tasks

Service Requesta request used to activate some flow or capability for a cross-cutting service. Can be achieved via an Async event or API call.

The magical build vs. buy question

In GBCs, buying products off the shelf is widespread as others have already solved them, and as this is not the company's differentiator, we will want to reduce the amount of work we need to put into these domains. Due to this, our mindset in those areas would be “Buy first. Only if there is nothing you can buy off the shelf, consider building it yourself”.

When->then to use GBC practical patterns

  • When there is more than one consumer -> then prefer to wrap the 3rd party integration in a cross-cutting service or package/middleware.
  • When there is a good amount of added logic to add to support our internal requirements -> use the GBC practical patterns (below), which are represented below, together with the cross-cutting service.

In this blog post, in the practical patterns, I will refer to the situations in which we needed to wrap a 3rd party tool in a cross-cutting service.

Dependencies, relationships, and maturity

To be able to define practical patterns for GBC, I have looked at how DDD defines those things —

  • It’s important to note that dependencies, relationships, and maturity types can also be found in supporting and core sub-domains. I have done this research with the specific perspective of generics, as currently, my domain is the platform.

BC dependency types

We first need to understand the relationships with the relevant team or BC to understand which practical technical patterns we want to choose for our GBCs.

Types of relationships

  1. Mutually dependent — Two teams or bounded contexts are mutually dependent when their software artifacts or systems need to be delivered together to be successful and work.
  2. Upstream Downstream — Actions of an upstream team will affect the downstream team, but the downstream's actions do not significantly impact the upstream team.
  3. Free — A Bounded Context or a team that works in it is free if changes in other Bounded Contexts do not influence its success or failure.

When reading the types, we can understand that in the GBC world, we are using the “upstream-downstream” relationship.

What are context maps?

Context Maps describe the relationships between bounded contexts and teams with a collection of patterns, and we can map them between the relationship types

  • Each name in each column represents a pattern from context mapping. Reading on each one can be found in the link above.Anti-corruption

Looking deeper into each relevant pattern, we can see that the responsibility between the customer and the supplier differ

BC Maturity levels

Working on infrastructure takes us, the techies, into thoughts on building big robust systems which are 100% self-serve. Though this is tempting, we will gradually want to mature into the most robust and complex solutions when building infrastructure, especially if it has multiple consumers and requires serving multiple use cases. These are the three main maturity types I have identified —

  • These can help us understand what state a GBC is in and help direct us to the correct practical pattern. We will use the characteristics to direct us to the relevant pattern that suites our maturity level.

GBC Practical Patterns

Before diving deeper into the patterns, I want to summarize what we have until now to ensure all the dots connect.

  • I am referring only to generics that have product requirements.
  • There is a practical pattern called “cross-cutting service” which represents a situation in which you need to wrap a 3rd party tool.
  • The relevant relationship type in our case is “upstream-downstream”, and the relevant context mapping patterns are — customer supplier, conformist, open host service, and anti-corruption layer.
  • Product maturity characteristics should indicate how mature a BC is and indicate which practical pattern to choose.

Let's get going :)

Customer supplier

Customer supplier is the most simple pattern of the lot and the least mature. Either side can dictate the interface, the customer does not have to align, and there is a mapping that will help customers avoid the need to align with every change in the interface (interface transitivity). This indicates that this pattern, as it's the simplest, should be our first pick when implementing a new GBC. It should help us build and learn fast to understand the requirements needed.

The biggest con of this pattern is that, as we add more and more consumers to the mapping layer, we will have a more challenging time transitioning to the conformist pattern. So when using this pattern, always ask yourself when your BC has matured enough to take the next step.

Practical pattern — generate a cross-cutting service that holds a mapping layer if needed which allows consumers to use the service but not align with it.

When->Then choose the customer supplier pattern (using maturity characteristics)?

This diagram shows a supplier service that either holds a mapping layer within it or on a different component for mapping consumers' domain events that are published from aggregates in our system. Due to the fact we consume domain events and not service requests, we do not align with the supplier interface, making it the customer-supplier pattern. In the case we extracted the mapping layer to a different component, we can see that this layer generates service requests.

Conformist

The conformist pattern is the next step after the customer-supplier. The most significant change between them is that the supplier dictates the interface, thus no need for a mapping layer, as consumers are using the interface directly. As there is no need for generalization of specific requirements or the need to enable generic configurations over the interface, we will remain with a simple API (no need for a complex DSL).

As this pattern presents a higher level of coupling with the customer, we should aim for lower interface transitivity.

Practical pattern — generate a cross-cutting service and define a simple generic interface.

When->Then choose the conformist pattern (using maturity characteristics)?

In this diagram we can see that the customer service needs to align using the service requests the supplier service exposes. The interface can be either by sync APIs or async message busses

Open-host service

The open-host service pattern differs from the conformist mainly in the requirement of needing a more complex interface (internal DSL) to

  • Provide a transformation engine.
  • Generate a great deal of abstraction to support requirements from numerous consumers.

Building a good DSL is a lot of work, and assuming we have reached this level of complexity after we have already gained some consumers, we expect the need for a migration plan or backward compatibility, meaning it can be a lot of work. We should think good and hard if we need this level of complexity and even consider challenging the product decisions that require us to have this.

Practical Pattern — generate a cross cutting service and define a DSL interface with a relevant DSL logic layer.

When->Then choose the open-host service pattern (using maturity characteristics)?

Anti-corruption layer

Last but not least is the anti-corruption layer. This pattern differs from the rest as it aims to help consumers use new services while avoiding aligning all their domains. In this case, the table is redundant as maturity, transitivity, and devX are not considered due to the cost of change.

Practical Pattern — the consumer will generate a mapping layer in an existing or new component. This layer will consume all requests from the consumer.

When->Then choose the open-host service pattern?

  • When wrapping a 3rd party vendor integration.
  • When aligning consumer interfaces cost is too high.

Example: Email service

To make my point even stronger, I want to give an example of an existing service we have in Augury, which we have yet to refactor but is a really good example to how practical patterns can be used in our world.

The “event-handler” service

The event-handler service in a legacy service that we have in our system, in the past it held a lot of centralized logic that was activated on consuming messages from our event bus. Today it remains mainly responsible for sending emails and SMSs to users.

Consumer-Producer

When this service was built, it was in the pattern of “consumer-producer” and looks like this:

In this diagram, we can see that we have a service called “maintenance-actions”. This service is part of our core domain and owned by a team in our core domain. Once a user adds a maintenance action called “repair”, this service sends a domain event called “user.action.add_repair”. The event handler holds within a mapping layer that contains business logic, which transforms this domain event into a filled email template that the email worker can consume once the email worker accepts the filled email template. It then sends it to Sengrid, which is our email vendor.

The first option for refactoring, to separate the business logic from the generic part, is to take out the mapping to a standalone component or service. The mapping layer will translate the domain events into the service requests the email service consumes. As this component remains a central mapping layer for all consumers in the system, it will remain in the ownership of the team responsible for the email worker.

Conformist

We can define a generic interface for our email service when extracting the mapping layer. New consumers can use it by sending service requests, keeping their business logic of building an email template encapsulated in their domain instead of doing so in the mapping layer.

The diagram shows that we have maintained the mapping layer for backward compatibility for all consumers. Still, new consumers can start using the new interface, stopping the increase in business logic in this generic area. Meaning now its a conformist pattern.

Open-host service

Once we have a conforming pattern and multiple consumers have migrated to using it, we are starting to see a new level of complexity rising as more and more consumers have requirements around our interface. For example, we could have recurring requirements around building the email templates in a more specific way. Instead of extending our interface with a new flag or section per consumer, we can build a generic DSL to meet the consumers' requirements. This added complexity to the generic interface but is preferred.

The DSL should be built in a backward compatible way as there are many conforming consumers, and the new requirements should be available for the consuming teams to migrate to on their own. This should also encourage teams working through the mapper to migrate to enable more capabilities using the new open-host interface, not the old one.

Anti-corruption layer

As I previously said, the anti-corruption layer is not another step in the maturity of our bounded context. It's a way for us to avoid migrating consumers from using conforming APIs.

In this diagram, we can see two places in which we incorporate the anti-corruption layer —

  • Event handler mapper — when talking in the consumer-producer pattern, I indicated that because this is centralized, we can keep this in the ownership of the email service team. Assuming this team stops supporting the mapper or any team decides to build this mapper on their own, it will be considered an anti-corruption layer.
  • Email worker — this layer in the email service is the layer that translates the generic email language that we have to a specific Sengrid language. This layer is essential to move between vendors in the future with as little change as possible. If the Sengrid language is available outside of it, the cost of change will be higher, so we choose not to introduce it to the consumer outside the anti-corruption layer.

Summary

Because building infrastructure is hard with many dependencies and because we want to enable an MVP mindset as much as possible. We will want to develop the skill set to understand the maturity level of the service we are building (like email as a service). We need to generate the right patterns when considering building a generic infrastructure that services product teams, not developers specifically. We have defined a cross-cutting service that is the bases of all of the patterns. Once we have that in place, we can start thinking about its relationship with the different bounded contexts in the company. According to the maturity of the generic service and its complexity, we will choose a specific context mapping pattern that also translates to a practical pattern.

Feel free to reach out with any ideas, comments, or thoughts.

Recommended reads

This is a list of reads I found insightful while building these guidelines —

--

--

Hila Fox
Machines talk, we tech.

Staff Engineer @ Forter. An experienced staff+ engineer with over 10 years of experience. #scale #distributed_systems #domain_driven_design