Merry Christmas Sale! 🎄 Ends in 6 days.
Friends, what an incredible year it has been. If I know us well, we're always moving forward. We're following our curiosity. And most importantly, we're growing.
Never forget one of the most important success techniques there is: reflection. So, take a moment to look back on how far you've come this year. Go on and collect the lessons, get inspired, and prepare for a prosperous 2025.
Before December 29th, you can get 50% off of Testing Mastery using the discount code XMAS-50 and 10% off the entire suite of courses in the academy using the discount code TSE-XMAS.
Thank you, and may 2025 grant you the growth, love & power to create what matters most in your world
Enjoy your holidays. See you next year.
And always, To Mastery!
How to Handle Updates on Aggregates - Domain-Driven Design w/ TypeScript
We cover this topic in The Software Essentialist online course. Check it out if you liked this post.
Also from the Domain-Driven Design with TypeScript series.
Introduction
A blog reader recently asked,
"Please suggest to me the best practices to deal with situations where a single field or only a few fields are present for an update against an aggregate".
Ah yes. Updates.
It's inevitable you'll want to perform update commands in a CRUD + MVC or DDD-based application. Everyone has their own way to handle updates in basic MVC applications, but it's not abundantly clear how to design updates against aggregates using DDD.
In the code sample, they had a User
aggregate, which looked more or less like this:
Note: I've left comments for improvements that I recommend based on patterns we've explored on this site in previous articles.
class User extends AggregateRoot {
// The user props.
// I would recommend extracting these to props like in the
// "Understanding Domain Entities" guide.
// The way it is currently, getters and setters are exposed
// for everything, and that's not very "Intention Revealing".
// Also makes it hard to restrict invalid operations (like
// manually changing the id) and makes it hard to enforce
// model invariants like ensuring the Phone is a valid
// value object instance (can currently assign to null).
// Let's take full advantage of the language capabilities.
id: number;
phone: Phone;
email: Email;
address: Address;
private constructor (id, phone, email, address){
// setting values here, I think
}
// Very good, there's a public factory method. Should type these
// props as their own interface UserProps {} though.
public static create (props) {
// There should be a Guard class here to ensure all props are valid.
return new User({ ...props });
}
// This is what's new. We'll talk about this. public static update (props) { return new User({ ...props }); }}
There's definitely ways we could improve User
aggregate, but the real pain points are felt from within the UsersService
's updatePhone
method (which is something that I would normally advocate for representing as an application layer use case) instead.
export class UserService {
...
public updatePhone (updatePhoneDto) {
const userEntity = userRepository.getUser(updatePhNoDto.userId);
const userModel = User.update({
id: updatePhNoDto.userId,
phone: Phone.create(userEntity.phone),
email: Email.create(userEntity.email),
address: Address.create(userEntity.address)
});
userRepository.updateUser(userModel)
}
}
The primary drawback to address in this code is the fact that the updatePhoneDto
probably has a shape like:
interface UpdatePhoneDto {
userId: number;
phone?: string;
email?: string;
address?: string;
}
And with all of those optional fields, we need to be able to "deal with situations where a single field or only a few fields are present for an update".
If any of either phone
, email
, or address
aren't present, we'll break each value object's create()
factory method.
That's not good.
What about non 1-to-1 relationships?
We also have to consider situations where we're not just updating 1-to-1 relationships like User
to Phone
or User
to Address
.
How do we handle updates against 1-to-many or many-to-many relationships?
For example, in White Label, an Album
can have many different Genre
s assigned to it.
We need some way that we can keep track or mark which Genres
were updated or removed in an update so that we can perform the correct persistence commands (insert? delete? update?).
It can get pretty complex. And that's a necessary complexity when we use domain models to encapsulate business rules and language within code.
The flow is straightforward though.
Plan of action for performing updates
Since in DDD, we usually implement the Data Mapper pattern, the object we retrieve from persistence before we update it will be a plain 'ol TypeScript object.
Our plan for performing an update against and aggregate will look like this:
-
- Fetch the aggregate (simple TypeScript object) we want to change.
-
- Change it.
-
- Pass it off to a repo to
save()
(or perhapsdelete()
).
- Pass it off to a repo to
The challenges at step 2 are:
- Protecting model integrity (class invariants)
- Performing validation logic
- Representing errors as domain concepts
- Keeping "update code" DRY
- Choosing the best ways to represent updates
The challenges at step 3 are:
- Performing an atomic update as a single transaction
- Knowing whether to perform an update, an insert or a delete based on the changes from the domain model.
- Scaffolding across foreign-key tables within the aggregate boundary.
Lets start with the basics.
In this article, we'll cover:
- How to handle updates (update, insert, delete) within aggregates with 1-to-1 relationships
- How mutations to your domain model can contain class invariants that need to be represented as domain concepts
We'll go into more advanced stuff like 1-to-many relationships in another article.
Prerequisites
In order to get the most out of this article, there are a few things that you might want to read first.
- The primary domain-driven design building blocks: domain entities, value objects, aggregate roots and aggregate boundaries.
- How to use DTOs, Repositories, and Mappers to persist and transform domain objects to other representations.
- The Command-Query Segregation Principle.
- How every feature of an application is a use case, which is either a command or a query.
A basic example (1-to-1)
Let's take the example we looked at before: the User
aggregate and it's relationship to phone
, email
and address
.
Creating the domain model
I'm going to model the User
aggregate a little differently than the example provided based on things we've covered in the Domain-Driven Design w/ TypeScript series already.
import { AggregateRoot } from "../../../core/domain/AggregateRoot";
import { UniqueEntityID } from "../../../core/domain/UniqueEntityID";
import { UserCreated } from '../events/userCreated'
import { Result } from "../../../core/logic/Result";
import { UserId } from "./userId";
import { Email } from "./email";
import { Phone } from "./phone";
import { Address } from "./address";
import { Guard } from "../../../core/logic/Guard";
interface UserProps {
phone: Phone;
email: Email;
address: Address;
}
export class User extends AggregateRoot<UserProps> {
// Only getters so far, no way to perform changes to
// the model after it's been created.
get userId (): UserId {
return UserId.create(this._id)
}
get phone (): Phone {
return this.props.phone;
}
get email (): Email {
return this.props.email;
}
get address (): Address {
return this.props.address;
}
private constructor (props: UserProps, id?: UniqueEntityID) {
super(props, id);
}
// The only way to create or reconstitute a User is to use the static factory
// method here.
public static create (props: UserProps, id?: UniqueEntityID): Result<User> {
const guardResult = Guard.againstNullOrUndefinedBulk([
{ argument: props.phone, argumentName: 'phone' },
{ argument: props.email, argumentName: 'email' },
{ argument: props.address, argumentName: 'address' }
]);
if (!guardResult.success) {
return Result.fail<User>(guardResult.message);
}
const isNewUser = !!id === false;
const user = new User(props, id);
// Dispatch a domain event if it's new. Otherwise, it's just an
// entity being reconstituted from persistence.
if (isNewUser) {
user.addDomainEvent(new UserCreated(user.userId))
}
return Result.ok<User>(user);
}
}
Cool, now let's talk about the use case.
Use Case setup
We want to provide a way to update the User
aggregate, so let's start by creating a new UpdateUser
use case in our use cases folder for the User
subdomain.
modules/
users/ # `users` subdomain
domain/
...
user.ts
...
useCases/
updateUser/
UpdateUser.ts # Use case!
export class UpdateUser implements UseCase<any, Promise<any>> {
constructor () {
// import dependencies
}
public async execute (): Promise<any> {
// execute application layer logic
}
}
If you've read the Clean Architecture vs. Domain-Driven Design concepts article, you'll remember that the responsibility of use cases at this layer are to simply fetch the domain objects we'll need to complete this operation, allow them to interact with each other (at the domain layer), and then save the transaction (by passing the affected aggregate root to it's repository).
That's just what we'll do.
Let's create a DTO in order to specify the inputs to this use case.
...
updateUser/
UpdateUser.ts # Use Case
UpdateUserDTO.ts # DTO
export interface UpdateUserDto {
userId: number;
phone?: string;
email?: string;
address?: string;
}
And we'll update our use case to use that as the input.
import { UpdateUserDTO } from './UpdateUserDTO'
export class UpdateUser implements UseCase<UpdateUserDTO, Promise<any>> { constructor () {
// import dependencies
}
public async execute (request: UpdateUserDTO): Promise<any> { // execute application layer logic
}
}
Cool. Now let's dependency inject a UserRepo
so that we can get access to the user
aggregate that we want to change in this transaction, then let's get it.
import { IUserRepo } from '../repos/interfaces/userRepo'
import { UpdateUserDTO } from './UpdateUserDTO'
export class UpdateUser implements UseCase<UpdateUserDTO, Promise<any>> {
private userRepo: IUserRepo;
constructor (userRepo: IUserRepo) {
this.userRepo = userRepo;
}
public async execute (request: UpdateUserDTO): Promise<any> {
let user: User;
try {
user = await this.userRepo.findUserById(request.userId);
} catch (err) {
// Handle not found error.
}
...
// Continue
}
}
I'm going to hook up some expressive error handling because I don't want a "Not Found" error to go unswallowed by the consumer of this use case.
Note: This is how we can strictly type and express any other application-level (use case) or domain layer errors that might get returned. The primary benefit of not throwing errors but instead representing them as first-class citizens of our app is that we force the client using our use case to handle the error states (that we know for sure will occur at some point).
Two new files.
...
updateUser/
UpdateUser.ts # Use Case
UpdateUserDTO.ts # DTO
UpdateUserErrors.ts # Holds all the unique types of application errors UpdateUserResult.ts # Express the result as a functional error type
Lets create the namespace to represent errors for this use case.
import { UseCaseError } from "../../../../../shared/core/UseCaseError";
import { Result } from "../../../../../shared/core/Result";
export namespace UpdateUserErrors {
export class UserNotFoundError extends Result<UseCaseError> {
constructor (userId: string) {
super(false, {
message: `Couldn't find user id {${userId}} to update.`
} as UseCaseError)
}
}
}
And then we'll create our strict return type so that clients can know what success and failure states to expect.
import { Either, Result } from "../../../../../shared/core/Result";
import { UpvotePostErrors } from "./UpvotePostErrors";
import { AppError } from "../../../../../shared/core/AppError";
export type UpdateUserResult = Either<
UpdateUserErrors.UserNotFoundError | // Specific use case error
AppError.UnexpectedError | // Global app error
Result<any>, // Misc errors (value objects)
Result<void> // Success!
>
Error handling articles: No idea what I'm doing? First read "Flexible Error Handling w/ the Result Class" and then read "Functional Error Handling with Express.js and DDD".
Then let's update the use case with the return type, represent the not found error, and represent the void
success state
import { IUserRepo } from '../repos/interfaces/userRepo'
import { UpdateUserDTO } from './UpdateUserDTO'
import { UpdateUserResult } from './UpdateUserResult'
import { UpdateUserErrors } from './UpdateUserErrors'
export class UpdateUser implements UseCase<UpdateUserDTO, Promise<UpdateUserResult>> { private userRepo: IUserRepo;
constructor (userRepo: IUserRepo) {
this.userRepo = userRepo;
}
public async execute (request: UpdateUserDTO): Promise<UpdateUserResult> { let user: User;
try {
user = await this.userRepo.findUserById(request.userId);
} catch (err) {
return left(new UpdateUserErrors.UserNotFoundError(request.userId)) }
// Update logic goes here
return right(Result.ok<void>()) }
}
Fantastic. We're all set up with to write some update logic now.
Do you know how the Repository pattern works? Do you know how to convert an ORM model (like Sequelize or TypeORM) to a domain object (like an Aggregate Root, Entity or Value Object)? If not, check out "Implementing DTOs, Mappers & the Repository Pattern using the Sequelize ORM [with Examples] - DDD w/ TypeScript".
Handling update logic
In this particular scenario, we have a DTO with several keys that we'd like to update: phone
, email
, address
.
Depending on the domain, each of these could possibly be modelled as simple value objects because they're more complex than simple string
types and have validation rules to dictate what a valid instance of them looks like.
We can start by using each value object's factory method (which encapsulates its respective validation logic).
import { IUserRepo } from '../repos/interfaces/userRepo'
import { UpdateUserDTO } from './UpdateUserDTO'
import { UpdateUserResult } from './UpdateUserResult'
import { UpdateUserErrors } from './UpdateUserErrors'
import { Phone } from '../domain/phone'
import { Email } from '../domain/email'
import { Address } from '../domain/addres'
export class UpdateUser implements UseCase<UpdateUserDTO, Promise<UpdateUserResult>> {
private userRepo: IUserRepo;
constructor (userRepo: IUserRepo) {
this.userRepo = userRepo;
}
public async execute (request: UpdateUserDTO): Promise<UpdateUserResult> {
let user: User;
try {
user = await this.userRepo.findUserById(request.userId);
} catch (err) {
return left(new UpdateUserErrors.UserNotFoundError(request.userId))
}
// Create value object instances
const phoneOrError: Result<Phone> = Phone.create(request.phone); const emailOrError: Result<Email> = Email.create(request.email); const addressOrError: Result<Address> = Address.create(request.address); ...
return right(Result.ok<void>())
}
}
The problem is that a request to this use case might not contain each of the keys on the dto interface. It's likely that we only want to update phone
but not also email
and address
in a single request.
We can write our own null checks or use a utility like lodash's has
function. It will return true
if a property exists on an object.
import { IUserRepo } from '../repos/interfaces/userRepo'
import { UpdateUserDTO } from './UpdateUserDTO'
import { UpdateUserResult } from './UpdateUserResult'
import { UpdateUserErrors } from './UpdateUserErrors'
import { Phone } from '../domain/phone'
import { Email } from '../domain/email'
import { Address } from '../domain/addres'
import { has } from 'lodash'
export class UpdateUser implements UseCase<UpdateUserDTO, Promise<UpdateUserResult>> {
private userRepo: IUserRepo;
constructor (userRepo: IUserRepo) {
this.userRepo = userRepo;
}
public async execute (request: UpdateUserDTO): Promise<UpdateUserResult> {
let user: User;
try {
user = await this.userRepo.findUserById(request.userId);
} catch (err) {
return left(new UpdateUserErrors.UserNotFoundError(request.userId))
}
if (has(request, 'phone')) { const phoneOrError: Result<Phone> = Phone.create(request.phone); // update }
if (has(request, 'email')) { const emailOrError: Result<Email> = Email.create(request.email); // update }
if (has(request, 'address')) { const addressOrError: Result<Address> = Address.create(request.address); // update }
...
return right(Result.ok<void>())
}
}
Awesome. Now we won't attempt to update something that's not even included in the request.
Focusing in on one of the keys (like phone
for example) if we were able to successfully create the value object, we can go ahead and update user
with it.
And if we weren't able to create it, yet we included some value for that key in the request, that probably means that there was a validation error that didn't pass and we should let the client know about that (matches the Result<any>
error type).
...
export class UpdateUser implements UseCase<UpdateUserDTO, Promise<UpdateUserResult>> {
...
public async execute (request: UpdateUserDTO): Promise<UpdateUserResult> {
...
if (has(request, 'phone')) {
const phoneOrError: Result<Phone> = Phone.create(request.phone);
if (phoneOrError.isSuccess) {
user.updatePhone(phoneOrError.getValue()) // This will be of Phone type.
} else {
return left(phoneOrError) // This will be a Result<any> return type.
}
}
...
return right(Result.ok<void>())
}
}
We have a new method on the user
aggregate called updatePhone(phone: Phone)
.
...
export class User extends AggregateRoot<UserProps> {
...
get phone (): Phone {
return this.props.phone;
}
public updatePhone (phone: Phone): void {
this.props.phone = phone;
}
...
}
Simple enough.
Here's where it gets interesting...
Atomic Transactions
Lets say we wanted to update phone
AND address
in single transaction.
If phone
was valid but address
was not, should we let the transaction partially update the user
aggregate or should the entire transaction fail?
It should fail, correct?
Things have the potential to get messy, code can get hard to debug, and things can be challenging to reason about if we were to allow partial transactions. A request to UPDATE
the user
has to be fully correct for it to succeed.
How do we do this?
Using our trusty Result<T>
class and its combine(results: Result<T>[])
method of course!~
We want to keep track of all the changes in a transaction, so let's add a changes: Result<T>[]
property and a couple of methods to manage it as well.
...
export class UpdateUser implements UseCase<UpdateUserDTO, Promise<UpdateUserResult>> {
private userRepo: IUserRepo;
private changes: Result<T>[];
constructor (userRepo: IUserRepo) {
this.userRepo = userRepo;
this.changes = []; }
public addChange (result: Result<any>) : void { this.changes.push(result); }
public getCombinedChangesResult (): Result<any> { return Result.combine(this.changes); }
...
}
The requirement now is that every time we mutate an aggregate, that needs to issue a Result<T>
.
Going back to our User
aggregate, we can change the method to accomodate our design decision.
...
export class User extends AggregateRoot<UserProps> {
...
get phone (): Phone {
return this.props.phone;
}
public updatePhone (phone: Phone): Result<void> { this.props.phone = phone; return Result.ok<void>(); }
...
}
This might not seem like a great design decision right away. In our updatePhone
method, the cyclomatic complexity hasn't really changed at all - there are the same number of code paths. So this additional complexity doesn't make a whole lot of sense upfront.
Note: After we finish this up, I'll show you an example of a real life aggregate that enforces class invariants that dictate when and how it's allowed to change. It makes much better use of the Result type.
Using this pattern, we can add each mutation against the user
aggregate to the changes array.
import { IUserRepo } from '../repos/interfaces/userRepo'
import { UpdateUserDTO } from './UpdateUserDTO'
import { UpdateUserResult } from './UpdateUserResult'
import { UpdateUserErrors } from './UpdateUserErrors'
import { Phone } from '../domain/phone'
import { Email } from '../domain/email'
import { Address } from '../domain/addres'
import { has } from 'lodash'
export class UpdateUser implements UseCase<UpdateUserDTO, Promise<UpdateUserResult>> {
private userRepo: IUserRepo;
private changes: Result<T>[];
constructor (userRepo: IUserRepo) {
this.userRepo = userRepo;
this.changes = [];
}
public addChange (result: Result<any>) : void {
this.changes.push(result);
}
public getCombinedChangesResult (): Result<any> {
return Result.combine(this.changes);
}
public async execute (request: UpdateUserDTO): Promise<UpdateUserResult> {
let user: User;
try {
user = await this.userRepo.findUserById(request.userId);
} catch (err) {
return left(new UpdateUserErrors.UserNotFoundError(request.userId))
}
if (has(request, 'phone')) {
const phoneOrError: Result<Phone> = Phone.create(request.phone);
if (phoneOrError.isSuccess) {
this.addChange( user.updatePhone(phoneOrError.getValue()) ) } else {
return left(phoneOrError)
}
}
if (has(request, 'email')) {
const emailOrError: Result<Email> = Email.create(request.email);
if (emailOrError.isSuccess) {
this.addChange( user.updateEmail(emailOrError.getValue()) ) } else {
return left(emailOrError)
}
}
if (has(request, 'address')) {
const addressOrError: Result<Address> = Address.create(request.address);
if (addressOrError.isSuccess) {
this.addChange( user.updateEmail(addressOrError.getValue()) ) } else {
return left(addressOrError)
}
}
...
return right(Result.ok<void>())
}
}
And then at the end, if all of the changes were successful, we can pass it to the repository to be saved.
...
export class UpdateUser implements UseCase<UpdateUserDTO, Promise<UpdateUserResult>> {
private userRepo: IUserRepo;
private changes: Result<T>[];
constructor (userRepo: IUserRepo) {
this.userRepo = userRepo;
this.changes = [];
}
public addChange (result: Result<any>) : void {
this.changes.push(result);
}
public getCombinedChangesResult (): Result<any> {
return Result.combine(this.changes);
}
public async execute (request: UpdateUserDTO): Promise<UpdateUserResult> {
...
if (this.getCombinedChangesResult().isSuccess) { try { // Save! await this.userRepo.save(user); } catch (err) { return left (AppError.create(err)) } }
return right(Result.ok<void>())
}
}
And the UserRepo
knows whether to perform an update
or a create
because it will first check to see if the entity exists or not.
export class SequelizeUserRepo implements UserRepo {
...
async save (user: User): Promise<void> {
const UserModel = this.models.BaseUser;
const exists = await this.exists(user.email);
if (!exists) {
const rawSequelizeUser = await UserMap.toPersistence(user);
await UserModel.create(rawSequelizeUser);
} else {
// Update!
}
return;
}
}
For more on using Repositories to persist domain entities, read "Implementing DTOs, Mappers & the Repository Pattern using the Sequelize ORM [with Examples]".
Extracting "change" functionality to a interface
Pretty much all update
use cases need to use this kind of change functionality so I'd recommend extracting it into it's own separate class and then using composition to add it to use cases that need it.
Design Principle: "Prefer composition over inheritance". - via solidbook.io: The Software Architecture & Design Handbook
import { Result } from "./Result";
// Use cases that need changes can implement this
export interface WithChanges {
changes: Changes;
}
// Extracted into its own class
export class Changes {
private changes: Result<any>[];
constructor () {
this.changes = [];
}
public addChange (result: Result<any>) : void {
this.changes.push(result);
}
public getCombinedChangesResult (): Result<any> {
return Result.combine(this.changes);
}
}
This changes the usage in our use case slightly:
import { IUserRepo } from '../repos/interfaces/userRepo'
import { UpdateUserDTO } from './UpdateUserDTO'
import { UpdateUserResult } from './UpdateUserResult'
import { UpdateUserErrors } from './UpdateUserErrors'
import { Phone } from '../domain/phone'
import { Email } from '../domain/email'
import { Address } from '../domain/addres'
import { has } from 'lodash'
export class UpdateUser implements UseCase<UpdateUserDTO, Promise<UpdateUserResult>>, WithChanges { private userRepo: IUserRepo;
public changes: Changes;
constructor (userRepo: IUserRepo) {
this.userRepo = userRepo;
this.changes = new Changes(); }
public async execute (request: UpdateUserDTO): Promise<UpdateUserResult> {
let user: User;
try {
user = await this.userRepo.findUserById(request.userId);
} catch (err) {
return left(new UpdateUserErrors.UserNotFoundError(request.userId))
}
if (has(request, 'phone')) {
const phoneOrError: Result<Phone> = Phone.create(request.phone);
if (phoneOrError.isSuccess) {
this.changes.addChange( user.updatePhone(phoneOrError.getValue()) ) } else {
return left(phoneOrError)
}
}
...
if (this.changes.getCombinedChangesResult().isSuccess) {
try {
// Save!
await this.userRepo.save(user);
} catch (err) {
return left (AppError.create(err))
}
}
return right(Result.ok<void>())
}
}
Representing invalid updates that break class invariants
I said I'd give you a better example of why using Result<T>
from within a domain entity is a good idea when we're performing mutations against it in the context of an update use case.
Here's one from DDDForum.com, the Hackernews-inspired forum app built with TypeScript & DDD from solidbook.io.
A Post
can have either a link
or text
, but not both.
And if you wish to change your link
or text
, you can only do so if the Post
doesn't yet have any comments.
See that? In this scenario, there are business rules that dictate when it's OK for something to change. That's the power of DDD and model-driven design. The shift that should happen in your thinking is to prefer trying to represent those invalid error states as domain concepts, rather than than throwing untyped errors.
Here's a snippet from the Post
aggregate.
/** Represents the different types of things that can happen when we try
* to update the post text or link.
*/
export type UpdatePostTextOrLinkResult = Either<
/**
* This error means that we're trying to update a text post when the
* post is actually a link post, or vice versa.
*/
EditPostErrors.InvalidPostTypeOperationError |
/**
* This error means that the post is sealed due to there already being
* comments
*/
EditPostErrors.PostSealedError |
Result<any>,
Result<void>
>
class Post extends AggregateRoot<PostProps> {
...
public updateText (postText: PostText): UpdatePostTextOrLinkResult {
if (!this.isTextPost()) {
return left(new EditPostErrors.InvalidPostTypeOperationError())
}
if (this.hasComments()) {
return left(new EditPostErrors.PostSealedError())
}
const guardResult = Guard.againstNullOrUndefined(postText, 'postText');
if (!guardResult.succeeded) {
return left(Result.fail<any>(guardResult.message))
}
this.props.text = postText;
return right(Result.ok<void>());
}
public updateLink (postLink: PostLink): UpdatePostTextOrLinkResult {
if (!this.isLinkPost()) {
return left(new EditPostErrors.InvalidPostTypeOperationError())
}
if (this.hasComments()) {
return left(new EditPostErrors.PostSealedError())
}
const guardResult = Guard.againstNullOrUndefined(postLink, 'postLink');
if (!guardResult.succeeded) {
return left(Result.fail<any>(guardResult.message))
}
this.props.link = postLink;
return right(Result.ok<void>());
}
}
Stay in touch!
Join 20000+ value-creating Software Essentialists getting actionable advice on how to master what matters each week. 🖖
View more in Domain-Driven Design