When starting a complex project like the one we are tackling, it's essential to gather as much context as possible while also approaching the domain knowledge from scratch and exploring it with the help of domain experts. This activity not only helps the technical team align with business goals but also creates a global map that lays the foundation for making better decisions throughout the product lifecycle.
To understand how the existing application worked, identify the various processes it executed, and determine how they were represented in the code, while establishing a common language with the previous team and current application users, we conducted initial sessions that were not very fruitful. At one point, we even considered whether we could move forward without these sessions and figure things out on our own, despite the potential for disruption.
EventStorming
From the beginning of the project, as the tech lead, given the complexity of the project and the need for the team to take real ownership of the product, I pushed to adopt a domain-driven approach to both the project and the team.
This approach allowed us to place the domain at the center of the narrative, aligning the entire team around a common language (ubiquitous language), which facilitated communication and enabled us to map the application's current state.
With this goal in mind, we formed the initial team and began preparing EventStorming sessions. This visual methodology helped us decompose the system’s key processes and identify relevant domain events and entities.
To facilitate the sessions, we used a Miro template that provided some guidance and a legend with key references to focus such sessions effectively. If you plan to conduct such a session, it’s helpful for participants to have a basic understanding of the concepts they’ll be working with, either through some pre-work or an explanation at the start of the session.
To maximize the effectiveness of these sessions, we developed a structured process divided into several phases:
-
Exploring Domain Events (Big picture)
- Identify the system's key events.
- Order them chronologically.
- Detect gaps and dependencies.
- Validate the sequence with domain experts.
-
Refinement and Analysis
- Add explanatory notes.
- Document doubts and questions.
- Delve deeper into each event.
- Highlight critical decision points.
-
Domain Modeling
- Identify aggregates and their boundaries.
- Define actors and their roles.
- Establish commands that trigger processes.
- Document policies and business rules.
- Identify internal and external triggers for each event.
-
Documentation and Validation
- Organize and clean the collected information.
- Establish clear relationships between elements.
- Validate the model with all stakeholders.
- Create reference documentation.
EventStorming not only helped us understand the domain but also served as the starting point for applying Domain-Driven Design (DDD) principles both strategic and tactical.
Strategic Domain-Driven Design
One of the most important aspects during the initial project phase was deciding how to structure the domain at a strategic level. The system's complexity, combined with the need to align technical and business goals, led us to adopt principles of Domain-Driven Design (DDD).
During this process, one of the most useful tools was Context Mapping. Although we were working with a monolith still awaiting refactoring, we took the time to identify various Bounded Contexts within the domain. Practically speaking, this meant that while we had described and defined multiple conceptual contexts, technically, we operated within a single Bounded Context with a shared kernel. This monolithic reality didn't prevent us from benefiting from this analysis.
Although we didn't delve deeply into Context Mapping, using these techniques at this stage was critical to ensuring the project had a clear path toward a domain-oriented architecture. This approach not only facilitated technical development but also fostered better collaboration between technical and business teams.
Defining Boundaries (Bounded Contexts)
Identifying the Bounded Contexts helped us better understand how different parts of the system related to each other and to external systems. This exercise not only helped us manage complexity more efficiently but also established a clear foundation for the project's evolutionary steps. As the system transitions to a more modular architecture, these initial decisions will guide us in breaking down the monolith into more manageable components aligned with the defined contexts.
It also helped us prioritize development efforts and identify parts of the system that could be simplified, decoupled, or eliminated. Technically, we focused on implementing Anti-Corruption Layers (ACL) to interact with external systems without compromising our system's integrity.
In our case, we initially identified five major contexts:
- Order Assignment
- Label Generation
- Order Preparation
- E-commerce Integration
- Bulk Order Preparation
These decisions not only established the foundation for a sustainable architecture but also ensured that development processes were more focused and aligned with business needs.
Ubiquitous Language
Another key aspect was establishing a solid ubiquitous language. The advantages of defining this language far outweigh the extra translation effort or the risk of misinterpreting concepts. Domain Experts and developers must actively collaborate to create this common language, which goes beyond a simple glossary of terms. It's a living, dynamic resource that connects the technical team with domain experts.
Implementing this ubiquitous anguage improved communication, reduced misunderstandings, and ensured the code faithfully represented the domain. Software development isn't just a technical activity; it's based on communication and collaboration. Thanks to the ubiquitous language, all team members work in alignment, resulting in more efficient technical solutions that better meet business needs.
Tactical Domain-Driven Design
Once we established the strategic framework, we moved on to implementing tactical DDD principles in the system. This allowed us to structure the code to reflect the domain's reality and ensure long-term sustainability.
Entities and Value Objects
Entities and Value Objects are two fundamental concepts in Domain-Driven Design (DDD). Understanding their differences and roles within the domain is crucial.
Entities
Entities are domain elements with a unique identity that persists over time, even if their attributes change. This unique identity distinguishes them from other elements of the same type. Additionally, entities usually have a defined lifecycle within the system. Examples of entities we identified in the project include:
- Order: Represents a purchase order with a unique identity and an associated state.
- Product: Catalog item with attributes like SKU code, price, and description.
- Carrier: Company responsible for delivering orders with specific service options.
- Shop: Identifies the e-commerce or platform where the order originated.
- Customer: The person or entity placing the order and receiving the product.
Value Objects
Value Objects, on the other hand, are domain elements without their own identity. Their value defines them, and two Value Objects with the same attributes are considered equivalent. This makes them immutable and ideal for encapsulating key concepts that may appear across various parts of the domain. Examples of Value Objects in the project include:
- ProductReference: Unique code identifying a product.
- ProductEan13: EAN-13 barcode associated with a product.
- OrderReference: Unique identifier for an order.
- Price: With currency and precise value.
- Weight: With standardized measurement units.
- ShippingNumber: Identification number for tracking.
This approach helps create more understandable and modular code, as each system element has a clear and well-defined responsibility.
Example of a Value Object:
<?php
readonly class ProductEan13
{
public string $value;
public function __construct(
string $value
)
{
$pattern = '/^\d{13}$/';
if (!preg_match($pattern, $value)) {
throw new \Exception('Invalid product Ean13');
}
$this->value = $value;
}
}
Services
In a complex application like ours, the concept of Services plays a central role in maintaining clean and modular code. Services are categorized into different layers and types based on their purpose and the patterns they implement. Let's explore how we organize and implement services effectively.
Domain Services
Domain Services encapsulate business logic that doesn't fit directly into any specific entity or value. These services operate strictly within domain rules and don't depend on infrastructure components.
<?php
class CheapestCarrierGetter
{
public function get(
DeliveryOptionCarrierCollection $deliveryOptionCarriers,
Weight $orderWeight,
Country $country,
PostalCode $postalCode,
bool $isCashOnDelivery = false,
): Carrier {
// Lògica per obtenir el transportista més econòmic
}
}
Application Services
Application Services encapsulate and coordinate logic that combines domain operations with external interactions to implement specific business or application scenarios. These services centralize and simplify the implementation of complex operations, ensuring a clear separation between the domain and infrastructure. The most well-known examples are use cases, but in our case, we will also have Command Handlers and Event Handlers.
Use Cases represent specific actions or processes that the application needs to perform to meet business requirements. They encapsulate the necessary logic to execute complex operations, including interactions with domain services and infrastructure, keeping the domain clean and focused. We could say that their responsibility should be only to orchestrate between these two layers, leaving the responsibility to the domain, as Vaughn Vernon says, "Keep Application Services thin, using them only to coordinate tasks on the model."
Command Handlers are the entry point for modifications to the domain. Upon receiving a Command, they validate the request, delegate the business logic to the corresponding aggregates, and finally, publish an event that reflects the change produced in the system.
On the other hand, Event Handlers act as listeners to changes in the system. They listen to events and execute specific actions in response, such as sending notifications, updating other aggregates, or integrating with external systems. This decoupled reaction allows for the construction of more flexible and scalable systems. I will expand on the details and concepts in future entries when we talk about the evolution of the application as an event-driven architecture (EDA) and queuing systems.
<?php
readonly class NotifyShopOnOrderShipped implements EventHandlerInterface
{
public function __construct(
private OrderRepositoryInterface $orderRepository,
private ExternalOrderUpdaterFactory $externalOrderUpdaterFactory,
private LoggerInterface $logger
) {}
public function __invoke(OrderShippedDomainEvent $event): void
{
// Lògica per notificar la botiga quan una comanda ha estat enviada
$order = $this->orderRepository->findById($event->getOrderId());
if (!$order) {
$this->logger->error("Comanda no trobada", ['orderId' => $event->getOrderId()]);
return;
}
$updater = $this->externalOrderUpdaterFactory->create($order);
$updater->updateStatus('shipped');
$this->logger->info("Notificació d'enviament completada", ['orderId' => $order->getId()]);
}
}
Infrastructure Services
Infrastructure Services implement operations that rely on external components such as databases, file systems, or third-party services. They often act as adapters to ensure that the domain remains agnostic to the specific implementation.
<?php
class MonologLogger implements LoggerInterface
{
private $logger;
public function __construct(Logger $logger)
{
$this->logger = $logger;
}
public function info(string $message, array $context = []): void
{
$this->logger->info($message, $context);
}
public function warning(string $message, array $context = []): void
{
$this->logger->warning($message, $context);
}
public function critical(string $message, array $context = []): void
{
$this->logger->critical($message, $context);
}
public function error(string $message, array $context = []): void
{
$this->logger->error($message, $context);
}
}
Service Classification
As we've seen, services help us decouple and decompose our application's logic into more granular and independent units. Here's a classification of common services we've identified in our project, grouped by their primary function and associated with known design patterns. This classification provides a more structured view of our architecture and facilitates decision-making when designing new features.
Service Type | Description | Pattern | Example |
---|---|---|---|
Transformers | Convert data between different formats or representations. | Adapter | Convert external API responses into domain models. |
Builders | Construct complex objects step by step. | Builder | Create an Order object. |
Factories | Create domain objects ensuring they comply with rules and restrictions. | Factory | Create instances of Product . |
Presenters | Format data for the user interface or API responses. | Decorator | Enrich data to display to the user. |
Notifiers | Send notifications (email, SMS, etc.). | Observer | Notify the user of important changes. |
Validators | Ensure that objects comply with business rules. | Strategy | Validate user requests or forms. |
Clients | Interact with external applications or services. | Proxy | Consume external APIs to obtain information. |
All these domain concepts are just a small sample of everything we managed to model. Obviously, we didn't do it all at once and we didn't get everything right the first time: there are many parts that still needed to be restructured, concepts that we didn't fully clarify and repositories that we knew didn't make sense, but that we had to maintain for functionalities, but that we had to end up eliminating.
But with this initial work done, it allowed the entire team to participate in this process and have a commitment to everything we were building.
To delve deeper into these DDD concepts and how to apply them, I recommend reading the following books:
- "Domain-Driven Design: Tackling Complexity in the Heart of Software" by Eric Evans: The foundational work on DDD that introduces and explains in depth the concepts of entities and value objects.
- "Implementing Domain-Driven Design" by Vaughn Vernon: A practical book that offers concrete examples of how to implement DDD in real-world projects.
- "Domain-Driven Design in PHP" by Carlos Buenosvinos, Christian Soronellas & Keyvan Akbary
Building the Team
The process of adopting DDD went hand-in-hand with the team's formation and evolution. This is where I want to talk about Tuckman's stages model (1965): Forming, Storming, Norming, Performing, as I believe the team's evolution followed this sequence and it helped us, on the one hand, to align our technical practices and on the other to develop a culture of collaboration and trust within the team. As Tuckman (1965) describes, understanding and embracing these stages is key to building high-performing teams. The original reference can be found in his work: Tuckman, B. W. (1965). Developmental sequence in small groups. Psychological Bulletin, 63(6), 384–399.
According to this model, teams go through four distinct phases to reach maximum performance. During the initial phase (Forming), team members introduce themselves and define general objectives. In the Storming phase, conflicts emerge as roles begin to be established. Over time, in the Norming phase, teams achieve a cohesive work dynamic, culminating in Performing, where they work efficiently towards common goals.
Forming
The team was initially formed with two developers who began reviewing the project and documenting its operation, while other members closed their respective projects to join. For my part, I joined as a tech lead and began reviewing the new documentation that had been generated and establishing the technical and organizational foundations of the project and the team in terms of processes, standards, and tools.
Storming
We couldn't say there were major disagreements within the team, but we did see the need to "regulate" certain aspects in order to be more aligned. This need became more apparent when we added two more people to the team and the roadmap began to take shape. This led us to consider how we wanted to work, communicate, and above all, how to make all these decisions and knowledge visible in a project that was constantly evolving.
So we quickly moved on to the Norming phase to establish the foundations of how we wanted to work and how we wanted to organize ourselves.
Norming
During this period, we agreed on several aspects to facilitate teamwork:
- Team Agreements: Rules for teamwork and communication such as defining ceremonies, meeting schedules, communication channels, etc.
- Coding Standards: Guidelines for writing clean and maintainable code following the foundations of DDD, Hexagonal Architecture, and SOLID principles.
- Development Process: Definition of the tools, processes, and practices to follow to maintain an efficient workflow, with the aim of improving code quality and team productivity, fostering a culture of collaboration and learning.
- WIP (Work In Progress) Limits: To ensure a sustainable workflow. It has a separate chapter to reinforce its importance, as part of the Lean methodology that we approached.
- Deployments: Rules for controlled and reliable deployments, including time limits and rollback procedures.
- Technical Debt: Definition and management of accumulated technical debt as well as providing a framework to identify and prioritize it if necessary.
- ADRs: Registration and justification of architectural decisions to ensure consistency and traceability of decisions made.
Performing
With all the work done in the previous stages, we had created a framework that allowed us to focus on product development efficiently and without worry, beyond the need to prioritize those initiatives that provided the most value in the context of each moment. This allowed the team to focus on implementing business functionalities while improving code quality, training, and keeping documentation alive.
Documentation
All this effort would be meaningless if it were not made visible in some way. We documented it so that it was accessible at any time and easy to update. We used GitLab to manage the project code, so we activated the GitLab Pages functionality and after a little research we used Jekyll with the Just the Docs theme.
To organize the information, we based ourselves on the Diátaxis model:
- Tutorials: Guides for application functionalities, mainly aimed at the end user (example: Transporter Configuration).
- Guides: Practical instructions and solutions to specific problems, for both end users and developers (example: How to install the application).
- Explanations: In-depth knowledge of team processes and standards (example: Team agreements and coding standards).
- References: Detailed information for developers (example: ADRs, application architecture).
A next step we didn't fully implement was automating technical documentation using Event Catalog and AsyncAPI. This would have allowed us to generate documentation directly from our codebase, ensuring that it was always up-to-date.
Top comments (0)