Services
"Service" is an overloaded concept and they're often over-used in non-DDD contexts. Let's find out how they are very selectively used in our context.

TL;DR
Services do things that don't quite fit in Entities or other objects. They are completely stateless.
Application services are excellent for wrapping non-domain actions like retrieving data from external systems, while domain services extend the possibility of acting within the domain. A good example of domain service usage is when you need to orchestrate Entities or Aggregates, especially as in our example code we don't have higher-level Aggregates that can hold such logic.
Services: An overloaded and problematic term. Still, we need them. What did Eric Evans himself actually write about them?
When a significant process or transformation in the domain is not a natural responsibility of an ENTITY or VALUE OBJECT, add an operation to the model as a standalone interface declared as a SERVICE. Define the interface in terms of the language of the model and make sure the operation name is part of the UBIQUITOUS LANGUAGE. Make the SERVICE stateless.—Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software (p. 106)
While we haven't gotten to Entities and Aggregates yet, it's safe to say that Services play in the next-highest league, metaphorically speaking.
In many projects, you might see services being used very broadly and liberally. This is similar to how in many Node/JS/TS projects you will find tons of helpers, utilities, or other functionally-oriented code. Unwittingly, this way of structuring code will introduce a flattening of hierarchies: Everything is on the same plane, meaning it's hard to understand how pieces fit together and what operates in which way on what.
Using a more object-oriented approach we can start enforcing a hierarchy like the below:
- Aggregate Root (if needed)
- Aggregate (if needed)
- Entity (if needed)
- Domain Service
- Application Service
- Value Object
Some of the solutions in the example code are actually basic enough that they need no Entity or higher-level constructs to deal with them (not even services!).
As said in the introduction, DDD is sometimes overkilling it by a stretch and then some.
Let's read what Evans writes about layering our services:
Application Layer: Defines the jobs the software is supposed to do and directs the expressive domain objects to work out problems. The tasks this layer is responsible for are meaningful to the business or necessary for interaction with the application layers of other systems. This layer is kept thin. It does not contain business rules or knowledge, but only coordinates tasks and delegates work to collaborations of domain objects in the next layer down. It does not have a state reflecting the business situation, but it can have a state that reflects the progress of a task for the user or the program.Domain Layer: Responsible for representing concepts of the business, information about the business situation, and business rules. State that reflects the business situation is controlled and used here, even though the technical details of storing it are delegated to the infrastructure. This layer is the heart of business software.
The intuitive difference should be clear, but I've found that it may take a refactoring or two to find the best balance, especially when balancing Domain Services and Aggregates.

Application Services reside in the Application layer.
Application Services and (Clean Architecture) use cases are somewhat equivalent, and we are using both concepts in our example code.
Use cases, like application services, contain no domain-specific business logic; can be used to fetch other domain Entities from external or internal (Repository) sources; may pass off control to Aggregates or Domain Services to execute domain logic; have low cyclomatic complexity.
The way I come to accept both co-existing is like this:
- The use case is strictly equivalent to the first testable complete unit of code. This is where we separate the Lambda infrastructure from the real code itself. This need does not in any way counter the application service notion.
- You can still use application services within the use case as these operate on the same overall conceptual application level and do things, rather than orchestrate them.
The main takeaway is that we understand that use cases and Application Services function practically the same, and are positionally equal.
You could, as I have done in other projects, use so-called "use case interactors" if you'd want to stay consistent with the terminology. In practice, however, I've actually only had to use such interactors (or if you'd rather: application services) in my most complex project, Figmagic. I've just never had to work on anything else that requires the abstraction, so don't go expecting that you need it for everything either.
The following is a concrete version of the
VerificationCodeService
used in the Reservation solution.code/Reservation/Reservation/src/application/services/VerificationCodeService.ts
1
/**
2
* @description The `OnlineVerificationCodeService` calls for an online service
3
* to retrieve and passes back a verification code.
4
*/
5
class OnlineVerificationCodeService implements VerificationCodeService {
6
private readonly securityApiEndpoint: string;
7
8
constructor(securityApiEndpoint: string) {
9
this.securityApiEndpoint = securityApiEndpoint;
10
if (!securityApiEndpoint) throw new MissingSecurityApiEndpoint();
11
}
12
13
/**
14
* @description Connect to Security API to generate code.
15
*/
16
async getVerificationCode(slotId: string): Promise<string> {
17
const verificationCode = await fetch(this.securityApiEndpoint, {
18
body: JSON.stringify({
19
slotId: slotId,
20
}),
21
method: "POST",
22
}).then((res: Response) => {
23
if (res?.status >= 200 && res?.status < 300) return res.json();
24
});
25
26
if (!verificationCode)
27
throw new FailedGettingVerificationCodeError("Bad status received!");
28
29
return verificationCode;
30
}
31
}
It has a single public method,
getVerificationCode()
. Using it, one can call an external endpoint and get the implied verification code. Because this is a straightforward and integration-oriented concern, and as we evidently can see there is no business logic here, it's safe to uncontroversially say that—indeed—we are dealing with an application service here.
Domain Services reside in the Domain layer.
Domain services encapsulate, as expected, domain logic — you'll therefore want this to match the ubiquitous language of your domain. Domain services would be recommended in case you have to interact with multiple Aggregates, for example, otherwise, keep it simple and let it be part of the Aggregate itself.
Next up we are going to check out one of the most important and longest classes in the entire codebase: The
ReservationService
.code/Reservation/SlotReservation/src/domain/services/ReservationService.ts
1
import { MikroLog } from "mikrolog";
2
3
// Aggregates/Entities
4
import { Slot } from "../entities/Slot";
5
6
// Events
7
import {
8
CancelledEvent,
9
CheckedInEvent,
10
CheckedOutEvent,
11
ClosedEvent,
12
CreatedEvent,
13
OpenedEvent,
14
ReservedEvent,
15
UnattendedEvent,
16
} from "../events/Event";
17
18
// Value objects
19
import { TimeSlot } from "../valueObjects/TimeSlot";
20
21
// Interfaces
22
import { SlotDTO, Status } from "../../interfaces/Slot";
23
import { Repository } from "../../interfaces/Repository";
24
import { Dependencies } from "../../interfaces/Dependencies";
25
import { ReserveOutput } from "../../interfaces/ReserveOutput";
26
import { MetadataConfigInput } from "../../interfaces/Metadata";
27
import { Event } from "../../interfaces/Event";
28
import { DomainEventPublisherService } from "../../interfaces/DomainEventPublisherService";
29
import { VerificationCodeService } from "../../interfaces/VerificationCodeService";
30
31
// Errors
32
import { MissingDependenciesError } from "../../application/errors/MissingDependenciesError";
33
34
/**
35
* @description Acts as the aggregate for Slot reservations (representing rooms and
36
* their availability), enforcing all the respective invariants ("statuses")
37
* of the Slot entity.
38
*/
39
export class ReservationService {
40
private readonly repository: Repository;
41
private readonly metadataConfig: MetadataConfigInput;
42
private readonly domainEventPublisher: DomainEventPublisherService;
43
private readonly logger: MikroLog;
44
45
constructor(dependencies: Dependencies) {
46
if (!dependencies.repository || !dependencies.domainEventPublisher)
47
throw new MissingDependenciesError();
48
const { repository, domainEventPublisher, metadataConfig } = dependencies;
49
50
this.repository = repository;
51
this.metadataConfig = metadataConfig;
52
this.domainEventPublisher = domainEventPublisher;
53
this.logger = MikroLog.start();
54
}
55
56
/**
57
* @description Utility to encapsulate the transactional boilerplate
58
* such as calling the repository and event emitter.
59
*/
60
private async transact(slotDto: SlotDTO, event: Event, newStatus: Status) {
61
// Omitted for brevity, clarity, scope
62
}
63
64
/**
65
* @description Make all the slots needed for a single day (same day/"today").
66
*
67
* "Zulu time" is used, where GMT+0 is the basis.
68
*
69
* @see https://time.is/Z
70
*/
71
public async makeDailySlots(): Promise<string[]> {
72
const slots: SlotDTO[] = [];
73
74
const startHour = 6; // Zulu time (GMT) -> 08:00 in CEST
75
const numberHours = 10;
76
77
for (let slotCount = 0; slotCount < numberHours; slotCount++) {
78
const hour = startHour + slotCount;
79
const timeSlot = new TimeSlot().startingAt(hour);
80
const slot = new Slot(timeSlot.get());
81
slots.push(slot.toDto());
82
}
83
84
const dailySlots = slots.map(async (slotDto: SlotDTO) => {
85
const slot = new Slot().fromDto(slotDto);
86
const { slotId, hostName, slotStatus, timeSlot } = slot.toDto();
87
88
const createdEvent = new CreatedEvent({
89
event: {
90
eventName: "CREATED", // Transient state
91
slotId,
92
slotStatus,
93
hostName,
94
startTime: timeSlot.startTime,
95
},
96
metadataConfig: this.metadataConfig,
97
});
98
99
await this.transact(slot.toDto(), createdEvent, slotStatus);
100
});
101
102
await Promise.all(dailySlots);
103
104
const slotIds = slots.map((slot: SlotDTO) => slot.slotId);
105
return slotIds;
106
}
107
108
/**
109
* @description Cancel a slot reservation.
110
*/
111
public async cancel(slotDto: SlotDTO): Promise<void> {
112
// Omitted for brevity, clarity, scope
113
}
114
115
/**
116
* @description Reserve a slot.
117
*/
118
public async reserve(
119
slotDto: SlotDTO,
120
hostName: string,
121
verificationCodeService: VerificationCodeService
122
): Promise<ReserveOutput> {
123
// Omitted for brevity, clarity, scope
124
}
125
126
/**
127
* @description Check in to a slot.
128
*/
129
public async checkIn(slotDto: SlotDTO): Promise<void> {
130
// Omitted for brevity, clarity, scope
131
}
132
133
/**
134
* @description Check out of a slot.
135
*/
136
public async checkOut(slotDto: SlotDTO): Promise<void> {
137
// Omitted for brevity, clarity, scope
138
}
139
140
/**
141
* @description Re-open a slot.
142
*/
143
public async open(slotDto: SlotDTO): Promise<void> {
144
// Omitted for brevity, clarity, scope
145
}
146
147
/**
148
* @description Check for closed slots and set them as being in "closed" invariant state.
149
*
150
* This is only triggered by scheduled events.
151
*/
152
public async checkForClosed(slotDtos: SlotDTO[]): Promise<void> {
153
// Omitted for brevity, clarity, scope
154
}
155
156
/**
157
* @description Close a slot.
158
*/
159
private async close(slot: Slot): Promise<void> {
160
// Omitted for brevity, clarity, scope
161
}
162
163
/**
164
* @description Check for unattended slots.
165
*/
166
public async checkForUnattended(slotDtos: SlotDTO[]): Promise<void> {
167
// Omitted for brevity, clarity, scope
168
}
169
170
/**
171
* @description Unattend a slot that has not been checked into.
172
*/
173
private async unattend(slot: Slot): Promise<void> {
174
// Omitted for brevity, clarity, scope
175
}
176
}
First of all, the service, even just by glancing at the method names, is clearly handling domain-specific concerns, such as
unattend()
, cancel()
, and makeDailySlots()
.Most of the code handles roughly similar functionality. For a telling example of the orchestration you might sometimes need, look no further than
makeDailySlots()
on line 70: This is domain logic that would not make sense inside the Slot
but makes perfect sense here in the outer scope. That comment might not make sense yet, but it will after the next couple of pages.When it gets constructed, it takes a number of dependencies to avoid creating its own imports and links to infrastructural objects. We make properties of the class
private
, and if we can, also readonly
. In this case, it's no problem to do so. For methods that are called in the use cases they are made public, or else they are private to discourage calling internal functionality from an unwitting outside party.The constructor had to evolve through a few iterations and it ultimately ended up taking in quite a bit of dependencies and configuration; all in all a good thing since it makes the
ReservationService
less coupled to any infrastructural concerns.We also have several custom errors that may be thrown if conditions are not valid.
private readonly repository: Repository;
private readonly metadataConfig: MetadataConfigInput;
private readonly domainEventPublisher: DomainEventPublisherService;
private readonly logger: MikroLog;
constructor(dependencies: Dependencies) {
if (!dependencies.repository || !dependencies.domainEventPublisher)
throw new MissingDependenciesError();
const { repository, domainEventPublisher, metadataConfig } = dependencies;
this.repository = repository;
this.metadataConfig = metadataConfig;
this.domainEventPublisher = domainEventPublisher;
this.logger = MikroLog.start();
}
Let's look closer at a use case-oriented method, like
cancel()
. That one looks roughly similar to most of the other operations.public async cancel(slotDto: SlotDTO): Promise<void> {
const slot = new Slot().fromDto(slotDto);
const { event, newStatus } = slot.cancel();
const cancelEvent = new CancelledEvent({
event,
metadataConfig: this.metadataConfig
});
await this.transact(slot.toDto(), cancelEvent, newStatus);
}
The method takes in the Data Transfer Object representation of the
Slot
. We reconstitute it by creating an actual Slot
Entity object from the DTO and then use the slot's own cancel()
method, in turn encapsulating the relevant business and validation logic.Given that nothing broke we can construct the
CancelledEvent
with the local metadata configuration and the event object we receive from the Slot
itself.Finally, it's time to run the domain service's
transact()
method that wraps the transactional boilerplate:private async transact(slotDto: SlotDTO, event: Event, newStatus: Status) {
await this.repository
.updateSlot(slotDto)
.then(() => this.logger.log(`Updated status of '${slotDto.slotId}' to '${newStatus}'`));
await this.repository.addEvent(event);
await this.domainEventPublisher.publish(event);
}
The
domainEventPublisher
will be discussed in the Events section.It might have been even nicer, though more work, to inject some type of service rather than the repository but at some point, we can just be "normal people" and accept the compromise of (in)directly using the repository in the domain layer.
Last modified 8mo ago