Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 17

Designing Stateless Domain Services

Recommended resources

Hello! Let's dive into our next lesson.

In our previous session, we established the theoretical distinction between Application Services (the orchestrators) and Domain Services (the keepers of core logic). We learned that Application Services handle the "how" of a use case, while Domain Services encapsulate the "what"—the complex business rules that don't belong to a single entity.

Today, we'll put that theory into practice. Our goal is to implement a stateless Domain Service in Python. This will involve taking a piece of significant business logic that involves multiple domain objects and encapsulating it in its own dedicated, testable component within the domain layer. This lesson directly addresses the learning outcome: implementing a stateless Domain Service for complex business rules that do not naturally fit within an entity or value object.

Let's get started.

1. Identifying the Need for a Domain Service

First, let's refresh our memory on when a Domain Service is the right tool for the job. You should consider creating one when you encounter a piece of logic that:

  1. Involves multiple domain objects: The operation requires coordinating or comparing several entities or aggregates. For example, calculating a shipping cost might depend on the Customer's location, the Product's weight, and the Warehouse's inventory.
  2. Represents a significant domain process: The operation is a "verb" in the Ubiquitous Language that business experts would recognize, like allocateStock, calculatePrice, or checkEligibility.
  3. Is stateless: The service performs a calculation or an action and returns a result. It doesn't hold any data itself between calls. Any state it needs is passed in as arguments.

A classic example is an allocation process. Imagine we need to allocate an incoming customer order line to one of many batches of stock. The rule might be: "Always try to allocate from batches in the warehouse first. If not possible, allocate from the incoming shipment with the earliest arrival date."

This logic doesn't belong to the OrderLine (it doesn't know about all batches) nor to a single Batch (it doesn't know about other batches to compare itself against). This is a perfect scenario for a Domain Service.


2. Implementation Style 1: The Standalone Function

Given your background in Python, you'll appreciate that in a multi-paradigm language, not everything needs to be a class. For stateless operations, a simple function is often the cleanest, most direct, and most "Pythonic" way to implement a Domain Service.

The book "Cosmic Python" by Harry Percival and Bob Gregory is an excellent resource that champions this approach. Let's see how they model the allocation problem we just described.

Domain Modeling

This chapter from 'Cosmic Python' demonstrates how to implement a Domain Service as a simple, standalone function. It's a fantastic example of clean, idiomatic Python that perfectly captures a complex business rule.

Please read the section titled 'Not Everything Has to Be an Object: A Domain Service Function' and the following short sections on magic methods and domain exceptions. You can start reading from the line 'We’ve made a model to represent batches...' and stop at the 'Domain Modeling Recap'. Focus on how the allocate function is implemented and tested.

Let's break down the key aspects of this implementation:

  • A Simple Function: The service is just def allocate(line: OrderLine, batches: List[Batch]) -> str:. It takes domain objects as input and returns a domain-relevant value (the batch reference). It's beautifully simple.
  • Statelessness: The function holds no state. All necessary data (line and batches) is passed in.
  • Expressing Domain Rules with Code: The business rule "allocate to warehouse stock first, then by earliest ETA" is elegantly handled by making the Batch entity sortable. By implementing the __gt__ magic method on Batch, the service can simply call sorted(batches) and trust that the list is in the correct order of preference. This is a powerful technique for embedding domain knowledge directly into your models.
  • Domain-Specific Exceptions: When no allocation is possible, the function doesn't return None or False. It raises a custom OutOfStock exception. This makes the outcome explicit and part of the Ubiquitous Language. An OutOfStock error is a meaningful event in the business domain, not just a technical failure.

3. Implementation Style 2: The Service Class

While a function is often perfect, sometimes a class is a more suitable structure for a Domain Service. This is common when:

  • You have several closely related operations that can be grouped together (e.g., FollowerService could have startFollowing, stopFollowing, getBlockedFollowers).
  • The service needs to use external infrastructure, like a repository, to perform its checks.

Let's look at an example of a class-based Domain Service. While the following video uses C#, the concepts and structure are directly applicable to Python. Your experience with Java will make the syntax easy to follow.

What is a Domain Service in Domain-Driven Design?

In this video, 'What is a Domain Service in Domain-Driven Design?', Milan Jovanović implements a FollowerService to handle the logic of one user following another. This is a great example of a class-based service.

Please watch the following segments: Creating the Service (00:03:13 - 00:04:50): Notice how the FollowerService class is created and the startFollowing method is defined. The inputs are two User entities. Implementing Rules (00:04:50 - 00:05:48): See the simple validation logic being added. Integrating Repository (00:05:48 - 00:09:10): This is the most important part. Pay close attention to how an IFollowerRepository interface is injected and used to check if the user is IsAlreadyFollowing. The author discusses the trade-offs of this decision. Recap (00:11:53 - 00:12:49): The author summarizes why a Domain Service was the right choice.

Let's translate this into Python and discuss the implications. A Python equivalent would look something like this:

# In domain/followers/repository.py (the interface)
class AbstractFollowerRepository(abc.ABC):
    @abc.abstractmethod
    def is_already_following(self, user_id: UUID, followed_user_id: UUID) -> bool:
        raise NotImplementedError
    
    @abc.abstractmethod
    def add(self, follower: Follower):
        raise NotImplementedError

# In domain/followers/services.py (the Domain Service)
class FollowerService:
    def __init__(self, repository: AbstractFollowerRepository):
        self._repository = repository

    def start_following(self, user: User, user_to_follow: User):
        if user.id == user_to_follow.id:
            raise CantFollowYourselfError() # Domain Exception

        if not user_to_follow.is_public:
            raise CantFollowPrivateProfileError() # Domain Exception

        if self._repository.is_already_following(user.id, user_to_follow.id):
            raise AlreadyFollowingError() # Domain Exception
        
        # If all rules pass, we can proceed.
        # The actual creation might be on the Follower entity via a factory method.
        new_follower = Follower.create(user.id, user_to_follow.id)
        
        # The service returns the new domain object.
        # The Application Service would then be responsible for adding it to the repository.
        return new_follower

Key Difference: The Repository Dependency

The most significant difference from the "Cosmic Python" approach is the dependency on a repository.

  • Pro: It allows the Domain Service to encapsulate all business rules, even those requiring database lookups (like "is already following?"). This prevents this logic from leaking into the Application Service, keeping the Application Service purely as an orchestrator.
  • Con: It makes the Domain Service less "pure." It now has a dependency (albeit on an abstraction) that needs to be mocked during unit testing. It slightly blurs the line between the domain and infrastructure, as the domain now has knowledge that persistence is required for some of its operations.

This is a common and pragmatic trade-off in DDD. The goal is to maximize encapsulation of business logic. If a database check is an integral part of a business rule, it's often better to inject a repository interface into the Domain Service than to split the rule across the Domain and Application layers.


Summary and Key Characteristics

Let's synthesize what we've learned from these two styles. A well-implemented Domain Service, whether a function or a class, will exhibit these characteristics:

  • Stateless: It does not maintain any state across different calls.
  • Domain-Centric Interface: Its parameters and return types are elements of the domain model (Entities, Value Objects), not primitive types like strings or integers where possible, and certainly not infrastructure-related objects.
  • Named After a Domain Process: The name of the service or function (e.g., allocate, FollowerService) is taken from the Ubiquitous Language.
  • Encapsulates Logic Involving Multiple Objects: It handles operations that don't naturally belong to any single entity.


Conclusion

In this lesson, we moved from theory to practice, exploring two powerful ways to implement a stateless Domain Service in Python.

Key Takeaways:

  • Domain Services are essential for encapsulating complex business logic that spans multiple domain objects.
  • In Python, a standalone function is often the simplest and most elegant implementation for a stateless service, as seen in the allocate example.
  • A class-based service is a good alternative when you need to group related operations or when the service's logic requires dependencies, such as a repository, to be fully encapsulated.
  • Regardless of the implementation style, a Domain Service must be stateless and have an interface defined in terms of the domain model.
  • Using domain-specific exceptions is a powerful way to make business rule violations an explicit part of your domain model.

In our next lesson, we will complete the picture by implementing an Application Service. You will see how an Application Service acts as the client for the domain model, orchestrating a use case by retrieving aggregates from a repository and calling a Domain Service like the ones we've designed today.