How to Design & Persist Aggregates - Domain-Driven Design w/ TypeScript

Translated by readers to: Japanese (日本語)
Can it be that it was all so simple then?
Remember when our apps were simple? Remember we could make CRUD API calls to our backend to change the state of the application? We were able to do that simply by using our Sequelize or Mongoose ORM models directly from our controllers.
Those were the good ol' days.
Look at us now. We're writing code that is pretty much a software implementation of the business. We're using Object-Oriented Programming principles to create rich domain models.
The rules and concepts that exist in the business in real life are now showing up in our code as entities and value objects. We're using use cases to express what all the different actors (groups of users) in our system can do from within their respective subdomains.
We're modeling software to solve complex real life business problems.
The challenge
A common recurring theme in software is relationships.
A large part of programming (especially Object-Oriented Programming) is about relationships. By decomposing gargantuan problems into smaller classes and modules, we're able to tackle small pieces of that complexity in bite-sized chunks.
This decomposition is what we've been doing with value objects to encapsulate validation logic.
interface GenreNameProps {
value: string
}
/**
* @class GenreName
* @description The genre name class is a Value Object that encapsulates
* the validation logic required to specify the name of a Genre.
* @see Genre entity
*/
export class GenreName extends ValueObject<GenreNameProps> {
private constuctor (props: GenreNameProps) {
super(props);
}
public static create (name: string) : Result<GenreName> {
const guardResult = Guard.againstNullOrUndefined(name, 'name');
if (!guardResult.isSuccess) {
return Result.fail<GenreName>(guardResult.error);
}
if (name.length <= 2 || name.length > 100) {
return Result.fail<GenreName>(
new Error('Name must be greater than 2 chars and less than 100.')
)
}
return Result.ok<GenreName>(new Name({ value: name }));
}
}
Instead of just using a "string", we use a GenreName class to encapsulate the validation logic that a Genre's name must be between 2 and 100 characters.
And we use entities to enforce model invariants, further decomposing the larger problem itself.
interface ArtistProps {
genres: Genre[];
name: string;
}
export class Artist extends Entity<ArtistProps> {
public static MAX_NUMBER_OF_GENRES_PER_ARTIST = 5;
private constructor (props: ArtistProps, id?: UniqueEntityId) : Artist {
super(props, id);
}
get genres (): Genre[] {
return this.props.genres;
}
...
/**
* @method addGenre
* @desc Notice this class encapsulates an important business rule about
* the 1-many relationship between Artist and Genre. We can only add
* a certain number of Genres to Artist.
*/
addGenre (genre: Genre): Result<any> {
if (this.props.genres.length >= Artist.MAX_NUMBER_OF_GENRES_PER_ARTIST) {
return Result.fail<any>('Max number of genres reached')
}
if (!this.genreAlreadyExists(genre)) {
this.props.genres.push(genre)
}
return Result.ok<any>();
}
}
Invariant/business rule: An artist can only have at most 5 genres.
As we define rules and constraints about how our isolated Domain Layer entities are allowed to relate to each other (1-to-1, 1-to-many, many-to-many), and which operations are valid at which times, several questions are introduced:
How do we (cascade and) save this cluster of entities to the database?
How do we decide on boundaries for all these entities in the cluster?
Do I need a repository for each of these entities?
In this article, I'll show you how we use Aggregates to create a boundary around a cluster of entities that we treat as a singular unit. I'll also show you how to persist them to a database.
What is an Aggregate?
The best definition of an aggregate comes from Eric Evans and his DDD book; in it, he says:
An "aggregate" is a cluster of associated objects that we treat as a unit for the purpose of data changes." - Evans. 126
Let's break that apart a bit.
An actual aggregate itself is the entire clump of objects that are connected together.
Take the following (semi) complete Vinyl
class from White Label - or see all the code here.
import { AggregateRoot } from "../../core/domain/AggregateRoot";
import { UniqueEntityID } from "../../core/domain/UniqueEntityID";
import { Result } from "../../core/Result";
import { Artist } from "./artist";
import { TraderId } from "../../trading/domain/traderId";
import { Guard } from "../../core/Guard";
import { VinylId } from "./vinylId";
import { VinylNotes } from "./vinylNotes";
import { Album } from "./album";
interface VinylProps {
traderId: TraderId;
artist: Artist;
album: Album;
vinylNotes?: VinylNotes;
dateAdded?: Date;
}
export type VinylCollection = Vinyl[];
export class Vinyl extends AggregateRoot<VinylProps> {
get vinylId(): VinylId {
return VinylId.create(this.id)
}
get artist (): Artist {
return this.props.artist;
}
get album (): Album {
return this.props.album;
}
get dateAdded (): Date {
return this.props.dateAdded;
}
get traderId (): TraderId {
return this.props.traderId;
}
get vinylNotes (): VinylNotes {
return this.props.vinylNotes;
}
private constructor (props: VinylProps, id?: UniqueEntityID) {
super(props, id);
}
public static create (props: VinylProps, id?: UniqueEntityID): Result<Vinyl> {
const propsResult = Guard.againstNullOrUndefinedBulk([
{ argument: props.album, argumentName: 'album' },
{ argument: props.artist, argumentName: 'artist' },
{ argument: props.traderId, argumentName: 'traderId' }
]);
if (!propsResult.succeeded) {
return Result.fail<Vinyl>(propsResult.message)
}
const vinyl = new Vinyl({
...props,
dateAdded: props.dateAdded ? props.dateAdded : new Date(),
}, id);
const isNewlyCreated = !!id === false;
if (isNewlyCreated) {
// TODO: Dispatch domain events
}
return Result.ok<Vinyl>(vinyl);
}
}
In White Label, Vinyl
is posted by the Trader
that owns it. Vinyl
has a relationship to an Artist
and a Album
.
That means Vinyl
has 3 different relationships to other entities.
Vinyl
belongs to (1-1) aTrader
Vinyl
has a (1-1)Artist
Vinyl
belongs to (1-1) anAlbum
Album
has many (1-m)Genres
Artist
has many (1-m)Genres
This relationship puts Vinyl
in the middle and makes Vinyl
the main entity in this clump: the aggregate root.
- An Aggregate is the clump of related entities to treat as a unit for data changes.
- The Aggregate Root is the main entity that holds references to the other ones. It's the only entity in the clump that is used for direct lookup.
Hopefully, you're still with me and we have a better understanding of what aggregates actually are.
We still have to talk about the second part of Evans' description about aggregates; particularly the part where he says that "we treat [them] as a unit for the purpose of data changes".
Figuring out boundaries
What's Evans talking about when he says we treat aggregates as a unit for data changes?
What are the data changes we're talking about?
Particularly the CREATE
, DELETE
, or UPDATE
-like operations. We want to make sure that we don't allow anything illegal to the domain to leave a domain model corrupted.
OK, and where do these data changes originate?
Data changes originate from the use cases our application fulfils. You know, the features. The app's entire reason for being.
Take White Label again. We've identified the majority of the use cases today for the Vinyl
entity (which we've determined is actually an Aggregate Root).
Some of the use cases make data changes (command) to the database, and some of them simply READ
(query) from the database.
-
Catalog
Use cases on Vinyl in my personal catalogaddVinyl
: add new existing vinylcreateNewVinyl
: create new vinylgetRecommendations
: get recommendations based on vinyl currently in cataloggetAllVinyl
: get all vinyl in cataloggetVinylByVinylId
: get particular vinyl in catalogremoveVinyl
: remove particular vinyl in catalogsearchCatalogForVinyl
: search for vinyl in catalogupdateVinyl
: update vinyl in catalog
-
Marketplace
Use cases on Vinyl in the public marketplacesearchMarketplaceForVinyl
: search the marketplace for vinylgetRecommendationsByVinylId
: get recommendations based on other users that have this vinyl
Great, we know the use cases for Vinyl
. Now what?
We can design the aggregate such that it enables all of the (command-like) use cases to be executed, while protecting any model invariants.
Ahh. And therein lies the trickiness.
And therein also lies our ability to determine aggregate boundaries. That's the goal in aggregate design.
We define the boundaries such a way that all of Vinyl
's COMMAND
use cases can be performed, and enough information is provided within the boundary to ensure that that no operation breaks any business rules.
However, it turns out that that's not always the easiest thing to get right the very first time.
Sometimes new business rules are introduced.
Sometimes new use cases are added.
Quite often, we end up being a bit off and have to make changes to our aggregate boundaries.
There are countless other essays, documents, books, resources, etc on effective aggregate design, and that's because it's so tricky to get right. There's a lot to consider.
Things to consider in aggregate design
If our goals in designing aggregates are:
- Provide enough info to enforce model invariants within a boundary
- Execute use cases
Then we also have to consider what we might be doing to the database and our transactions in making overly large aggregate boundaries.
Database / Transaction Performance
At this point with our boundaries on Vinyl
, think about how many tables we need to join in order to retrieve a single Vinyl
.
Let's add another goal to our aggregate design list:
- Provide enough info to enforce model invariants within a boundary
- Execute use cases
- Ensure decent database performance
DTOs
There's also the case of DTOs. Quite often, we need to map a Domain entity to a DTO / View Model to return to the user.
In this case of Vinyl
in White Label, we might need to return something like looks like this to the user:
There's the actual ArtistName
, the artwork, the year
it was released and more.
Our DTO might need to look like the following in order to build this view on the frontend.
interface GenreDTO {
genre_id: string;
name: string;
}
interface ArtistDTO {
artist_id: string;
name: string;
artist_image: string;
genres?: GenreDTO[];
}
interface AlbumDTO {
name: string;
yearReleased: number;
artwork: string;
genres?: GenreDTO[];
}
export interface VinylDTO {
vinyl_id: string;
trader_id: string;
title: string;
artist: ArtistDTO;
album: AlbumDTO;
}
That realization might really force us ensure we include the entire Artist
and Album
entities inside the aggregate boundary for Vinyl
because it might make it easier for us to build API response DTOs.
That gives us another goal in aggregate design:
- Provide enough info to enforce model invariants within a boundary
- Execute use cases
- Ensure decent database performance
- (optional, not recommended though) Provide enough info to transform a Domain Entity to a DTO
The reason why this is an optional goal stems from the CQS (Command-Query Segregation) Principle.
Command-Query Segregation
CQS says that any operation is either a COMMAND
or a QUERY
.
So if a function performs a COMMAND
, it has no return value (void
), like so:
// Valid
function createVinyl (data): void {
... // create and save
}
// Valid
function updateVinyl (vinylId: string, data): void {
... // update
}
// Invalid
function updateVinyl (vinylId: string, data): Vinyl {
... // update
}
When we make changes to our aggregates (UPDATE
, DELETE
, CREATE
), we're performing COMMAND
s.
In this scenario, we need to have a complete aggregate pulled into memory in order to enforce any invariant rules before approving the COMMAND
or rejecting it because some business rule isn't being satisfied.
OK. Makes sense.
But queries are different. QUERIES
simply return a value but also produce absolutely no side-effects.
// Valid
function getVinylById (vinylId: string): Vinyl {
// returns vinyl
}
// Invalid
function getVinylById (vinylId: string): Vinyl {
const vinyl = this.vinylRepo.getVinyl(vinylId);
await updateVinyl(vinyl) // bad, side-effect of a QUERY
// returns vinyl
return vinyl;
}
So in the context of DTOs, adding additional info to our aggregate for the sake of having them available for our DTOs has potential to hurt performance, don't do it.
DTOs can have a tight requirement to fulfill a user inferface, so instead of filling up an aggregate with all that info, just retrieve the data you need, directly from the repository/repositories to create the DTO.
Hopefully you're understanding that aggregate design takes a little bit of work.
Like most things in software development, there's no free lunch. You have to consider the tradeoffs in simplicity vs. performance.
I'll always recommend to start with simplicity and then address performance later if necessary.
"Add Vinyl" Use Case
To get the hang of designing aggregates and persisting them, let's walk through building something.
I'm currently working on a Domain Driven Design w/ TypeScript Course where we build a Vinyl Trading application.
You can check out the code for the project here on GitHub.
One of the use cases is to be able to add vinyl that you currently have in your collection at home so that you can then make trades and receive offers on them.
The flow
Starting from an empty page, we're presented with the ability to Add Vinyl.
Empty collection page.
When we click on "Add Vinyl", we can fill out a form starting with the Artist
.
If the Artist
isn't currently in our system, then the user will need to Create a new artist as well.
Add new vinyl - Enter the artist, no auto-suggest
Clicking "+ Create new artist [Artist name]" will open up more details for the user to fill out like the Genres
of this artist.
Add new vinyl - Fill in addition New Artist details
When they're completed with that, they can add the Album
details.
Add new vinyl - Add new album.
Again, if the album hasn't ever been added to the platform, they'll be required to fill in all the details manually.
Add new vinyl - Fill in album details.
Lastly, they can add any relevant information about their copy of the record before hitting submit.
Add new vinyl - Submission
And then it should show up on their dashboard. The album artwork can be retrieved from a music metadata API and then ammended to the Vinyl
through an application service in the backend that listens for a VinylCreatedEvent
.
Developing the Use Case
Since we're sure that Vinyl
is going to be our Aggregate Root, let's talk about what we need in the AggregateRoot
class.
Our basic Aggregate Root class
First off, an Aggregate Root is still an Entity
, so let's simply extend our existing Entity<T>
class.
import { Entity } from "./Entity";
import { UniqueEntityID } from "./UniqueEntityID";
export abstract class AggregateRoot<T> extends Entity<T> {
}
What else should an aggregate root class be responsible for?
Dispatching Domain Events.
We won't get into it now, but in a future article you'll see how we can apply the observer pattern in order to signal when relevant things happen, directly from domain layer itself.
But for now, this is good enough to simply give the class name intent.
import { AggregateRoot } from "../../core/domain/AggregateRoot";
...
export class Vinyl extends AggregateRoot<VinylProps> {
...
}
If you wanna skip ahead and see what this class will look like when we hook up domain events, feel free to check out the code here.
Now let's get to the Use Case.
The Add Vinyl To Catalog Use Case
Let's think about the general algorithm here:
Given a request DTO containing:
- the current user's id
- the vinyl details containing:
- artist details
- artist id if it already existed
- name and genres if it didn't
- album details
- albumId if it already existed
- name, year and genres if it didn't exist
- where each genre also contains:
- the genre id if it already existed
- the genre name if it didn't already exist
We want to:
- Find or create the artist
- Find or create the album
- Create the new vinyl (artist, album, traderId)
- Save the new vinyl
Looks good, let's start with the request DTOs. What do we need from the API call?
Request DTOs
For this API call to account for situations where the Genres
exist and when they don't exist, we'll split a GenresRequestDTO
into two parts.
interface GenresRequestDTO {
new: string[];
ids: string[];
}
Here's the breakdown of this approach:
new: string[]
contains text values of new genresids: string[]
contains the ids of the genres that we want to link.
Since Album
and Artist
both take Genres
, we'll give them each their own key in the main payload.
interface AddVinylToCatalogUseCaseRequestDTO {
artistNameOrId: string;
artistGenres: string | GenresRequestDTO;
albumNameOrId: string;
albumGenres: string | GenresRequestDTO;
albumYearReleased: number;
traderId: string;
}
You'll also notice that we've named the Artist
name and the Album
name as [albumName/artistName]orId
. That's because if the Album
already exists, we can just include the id. Same for Artist
.
Now let's hook these all up to an AddVinylToCatalogUseCase
like we've done in our previous articles on creating use cases.
Creating the AddVinylToCatalogUseCase
class
// Lots of imports
import { UseCase } from "../../../../core/domain/UseCase";
import { Vinyl } from "../../../domain/vinyl";
import { IVinylRepo } from "../../../repos/vinylRepo";
import { Result } from "../../../../core/Result";
import { TextUtil } from "../../../../utils/TextUtil";
import { IArtistRepo } from "../../../repos/artistRepo";
import { Artist } from "../../../domain/artist";
import { TraderId } from "../../../../trading/domain/traderId";
import { UniqueEntityID } from "../../../../core/domain/UniqueEntityID";
import { ArtistName } from "../../../domain/artistName";
import { ParseUtils } from "../../../../utils/ParseUtils";
import { GenresRepo, IGenresRepo } from "../../../repos/genresRepo";
import { Genre } from "../../../domain/genre";
import { Album } from "../../../domain/album";
import { IAlbumRepo } from "../../../repos/albumRepo";
import { GenreId } from "../../../domain/genreId";
interface GenresRequestDTO {
new: string[];
ids: string[];
}
interface AddVinylToCatalogUseCaseRequestDTO {
artistNameOrId: string;
artistGenres: string | GenresRequestDTO;
albumNameOrId: string;
albumGenres: string | GenresRequestDTO;
albumYearReleased: number;
traderId: string;
}
export class AddVinylToCatalogUseCase implements UseCase<AddVinylToCatalogUseCaseRequestDTO, Result<Vinyl>> {
private vinylRepo: IVinylRepo;
private artistRepo: IArtistRepo;
private albumRepo: IAlbumRepo;
private genresRepo: IGenresRepo;
// Make sure to dependency inject repos that we
// need, only referring to their interface. Never
// their concrete class.
constructor (
vinylRepo: IVinylRepo,
artistRepo: IArtistRepo,
genresRepo: GenresRepo,
albumRepo: IAlbumRepo
) {
this.vinylRepo = vinylRepo;
this.artistRepo = artistRepo;
this.genresRepo = genresRepo;
this.albumRepo = albumRepo;
}
private async getGenresFromDTO (artistGenres: string) {
// TODO:
}
private async getArtist (request: AddVinylToCatalogUseCaseRequestDTO): Promise<Result<Artist>> {
// TODO:
}
private async getAlbum (request: AddVinylToCatalogUseCaseRequestDTO, artist: Artist): Promise<Result<Album>> {
// TODO:
}
public async execute (request: AddVinylToCatalogUseCaseRequestDTO): Promise<Result<Vinyl>> {
const { traderId } = request;
let artist: Artist;
let album: Album;
try {
// Get the artist
const artistOrError = await this.getArtist(request);
if (artistOrError.isFailure) {
return Result.fail<Vinyl>(artistOrError.error);
} else {
artist = artistOrError.getValue();
}
// Get the album
const albumOrError = await this.getAlbum(request, artist);
if (albumOrError.isFailure) {
return Result.fail<Vinyl>(albumOrError.error);
} else {
album = albumOrError.getValue();
}
// Create vinyl
const vinylOrError = Vinyl.create({
album: album,
artist: artist,
traderId: TraderId.create(new UniqueEntityID(traderId)),
});
if (vinylOrError.isFailure) {
return Result.fail<Vinyl>(vinylOrError.error)
}
const vinyl = vinylOrError.getValue();
// Save the vinyl
// This is where all the magic happens
await this.vinylRepo.save(vinyl);
return Result.ok<Vinyl>(vinyl)
} catch (err) {
console.log(err);
return Result.fail<Vinyl>(err);
}
}
}
At this point, you can see the general shape of the algorithm we defined earlier.
Our goal with this Use Case is to retrieve everything necessary to create a Vinyl
in memory, and then pass it to the VinylRepo
to cascade everything that needs to be saved.
All that's left to do in this class is to fill in the blanks for how we retrieve everything.
Also, let me remind you that you can view the entire source code here.
Take a look.
import { UseCase } from "../../../../core/domain/UseCase";
import { Vinyl } from "../../../domain/vinyl";
import { IVinylRepo } from "../../../repos/vinylRepo";
import { Result } from "../../../../core/Result";
import { TextUtil } from "../../../../utils/TextUtil";
import { IArtistRepo } from "../../../repos/artistRepo";
import { Artist } from "../../../domain/artist";
import { TraderId } from "../../../../trading/domain/traderId";
import { UniqueEntityID } from "../../../../core/domain/UniqueEntityID";
import { ArtistName } from "../../../domain/artistName";
import { ParseUtils } from "../../../../utils/ParseUtils";
import { GenresRepo, IGenresRepo } from "../../../repos/genresRepo";
import { Genre } from "../../../domain/genre";
import { Album } from "../../../domain/album";
import { IAlbumRepo } from "../../../repos/albumRepo";
import { GenreId } from "../../../domain/genreId";
interface GenresRequestDTO {
new: string[];
ids: string[];
}
interface AddVinylToCatalogUseCaseRequestDTO {
artistNameOrId: string;
artistGenres: string | GenresRequestDTO;
albumNameOrId: string;
albumGenres: string | GenresRequestDTO;
albumYearReleased: number;
traderId: string;
}
export class AddVinylToCatalogUseCase implements UseCase<AddVinylToCatalogUseCaseRequestDTO, Result<Vinyl>> {
private vinylRepo: IVinylRepo;
private artistRepo: IArtistRepo;
private albumRepo: IAlbumRepo;
private genresRepo: IGenresRepo;
constructor (
vinylRepo: IVinylRepo,
artistRepo: IArtistRepo,
genresRepo: GenresRepo,
albumRepo: IAlbumRepo
) {
this.vinylRepo = vinylRepo;
this.artistRepo = artistRepo;
this.genresRepo = genresRepo;
this.albumRepo = albumRepo;
}
private async getGenresFromDTO (artistGenres: string) {
return (
await this.genresRepo.findByIds(
((ParseUtils.parseObject(artistGenres) as Result<GenresRequestDTO>)
.getValue()
.ids
// existing ids, we're converting them into genreIds so
// that we can pass them into genresRepo.findByIds(genreIds: GenreId[])
.map((genreId) => GenreId.create(new UniqueEntityID(genreId))
))
))
// Join both groups of ids together. New and old.
.concat(
((ParseUtils.parseObject(artistGenres) as Result<GenresRequestDTO>)
.getValue()
.new // new genres.. let's create 'em
).map((name) => Genre.create(name).getValue())
)
}
private async getArtist (request: AddVinylToCatalogUseCaseRequestDTO): Promise<Result<Artist>> {
const { artistNameOrId, artistGenres } = request;
const isArtistIdProvided = TextUtil.isUUID(artistNameOrId);
if (isArtistIdProvided) {
const artist = await this.artistRepo.findByArtistId(artistNameOrId);
const found = !!artist;
if (found) {
return Result.ok<Artist>(artist);
} else {
return Result.fail<Artist>(`Couldn't find artist by id=${artistNameOrId}`);
}
}
else {
return Artist.create({
name: ArtistName.create(artistNameOrId).getValue(),
genres: await this.getGenresFromDTO(artistGenres as string)
})
}
}
private async getAlbum (request: AddVinylToCatalogUseCaseRequestDTO, artist: Artist): Promise<Result<Album>> {
const { albumNameOrId, albumGenres, albumYearReleased } = request;
const isAlbumIdProvided = TextUtil.isUUID(albumNameOrId);
if (isAlbumIdProvided) {
const album = await this.albumRepo.findAlbumByAlbumId(albumNameOrId);
const found = !!album;
if (found) {
return Result.ok<Album>(album)
} else {
return Result.fail<Album>(`Couldn't find album by id=${album}`)
}
} else {
return Album.create({
name: albumNameOrId,
artistId: artist.artistId,
genres: await this.getGenresFromDTO(albumGenres as string),
yearReleased: albumYearReleased
})
}
}
public async execute (request: AddVinylToCatalogUseCaseRequestDTO): Promise<Result<Vinyl>> {
const { traderId } = request;
let artist: Artist;
let album: Album;
try {
const artistOrError = await this.getArtist(request);
if (artistOrError.isFailure) {
return Result.fail<Vinyl>(artistOrError.error);
} else {
artist = artistOrError.getValue();
}
const albumOrError = await this.getAlbum(request, artist);
if (albumOrError.isFailure) {
return Result.fail<Vinyl>(albumOrError.error);
} else {
album = albumOrError.getValue();
}
const vinylOrError = Vinyl.create({
album: album,
artist: artist,
traderId: TraderId.create(new UniqueEntityID(traderId)),
});
if (vinylOrError.isFailure) {
return Result.fail<Vinyl>(vinylOrError.error)
}
const vinyl = vinylOrError.getValue();
// This is where all the magic happens
await this.vinylRepo.save(vinyl);
return Result.ok<Vinyl>(vinyl)
} catch (err) {
console.log(err);
return Result.fail<Vinyl>(err);
}
}
}
You've made it pretty far in this article. Let's recap what we've done so far in the use case and what's next.
- We've created a DTO that enables us to use existing or new genres, albums, and artists to create vinyl.
- We've written code that will pull in all the resources necessary to create a vinyl (album, artist, traderId).
- Next, we have to persist it by looking at the
Vinyl
aggregate and passing parts of it to the correct repositories. - A repository will
save()
our aggregate and manage cascading the rest of the complex persistence code.
Persisting an aggregate
Ready to finish it off?
Let's see how we can persist this aggregate.
Take a look at the VinylRepo
class', particularly the save()
method.
VinylRepo (saves vinyl)
import { Repo } from "../../core/infra/Repo";
import { Vinyl } from "../domain/vinyl";
import { VinylId } from "../domain/vinylId";
import { VinylMap } from "../mappers/VinylMap";
import { TraderId } from "../../trading/domain/traderId";
import { IArtistRepo } from "./artistRepo";
import { IAlbumRepo } from "./albumRepo";
export interface IVinylRepo extends Repo<Vinyl> {
getVinylById (vinylId: VinylId): Promise<Vinyl>;
getVinylCollection (traderId: string): Promise<Vinyl[]>;
}
export class VinylRepo implements IVinylRepo {
private models: any;
private albumRepo: IAlbumRepo;
private artistRepo: IArtistRepo;
constructor (models: any, artistRepo: IArtistRepo, albumRepo: IAlbumRepo) {
this.models = models;
this.artistRepo = artistRepo;
this.albumRepo = albumRepo;
}
private createBaseQuery (): any {
const { models } = this;
return {
where: {},
include: [
{
model: models.Artist, as: 'Artist',
include: [
{ model: models.Genre, as: 'ArtistGenres', required: false }
]
},
{
model: models.Album, as: 'Album',
include: [
{ model: models.Genre, as: 'AlbumGenres', required: false }
]
}
]
}
}
public async getVinylById (vinylId: VinylId | string): Promise<Vinyl> {
const VinylModel = this.models.Vinyl;
const query = this.createBaseQuery();
query.where['vinyl_id'] = (
vinylId instanceof VinylId ? (<VinylId>vinylId).id.toValue() : vinylId
);
const sequelizeVinylInstance = await VinylModel.findOne(query);
if (!!sequelizeVinylInstance === false) {
return null;
}
return VinylMap.toDomain(sequelizeVinylInstance);
}
public async exists (vinylId: VinylId | string): Promise<boolean> {
const VinylModel = this.models.Vinyl;
const query = this.createBaseQuery();
query.where['vinyl_id'] = (
vinylId instanceof VinylId ? (<VinylId>vinylId).id.toValue() : vinylId
);
const sequelizeVinylInstance = await VinylModel.findOne(query);
return !!sequelizeVinylInstance === true;
}
public async getVinylCollection (traderId: TraderId | string): Promise<Vinyl[]> {
const VinylModel = this.models.Vinyl;
const query = this.createBaseQuery();
query.where['trader_id'] = (
traderId instanceof TraderId ? (<TraderId>traderId).id.toValue() : traderId
);
const sequelizeVinylCollection = await VinylModel.findAll(query);
return sequelizeVinylCollection.map((v) => VinylMap.toDomain(v));
}
private async rollbackSave (vinyl: Vinyl) {
const VinylModel = this.models.Vinyl;
await this.artistRepo.removeArtistById(vinyl.artist.artistId);
await this.albumRepo.removeAlbumById(vinyl.artist.artistId);
await VinylModel.destroy({
where: {
vinyl_id: vinyl.vinylId.id.toString()
}
})
}
public async save (vinyl: Vinyl): Promise<Vinyl> {
const VinylModel = this.models.Vinyl;
const exists: boolean = await this.exists(vinyl.vinylId);
const rawVinyl: any = VinylMap.toPersistence(vinyl);
try {
await this.artistRepo.save(vinyl.artist);
await this.albumRepo.save(vinyl.album);
if (!exists) {
await VinylModel.create(rawVinyl);
} else {
await VinylModel.update(rawVinyl);
}
} catch (err) {
this.rollbackSave(vinyl);
}
return vinyl;
}
}
In VinylRepo
, we have methods for retrieving Vinyl
by id, checking for it's existence, and getting the entire collection for Trader
.
Let's walk through the save()
method.
export class VinylRepo implements IVinylRepo {
...
public async save (vinyl: Vinyl): Promise<Vinyl> {
// 1. Get access to the Sequelize ORM vinyl model. Our repo
// simply encapsulates access to this.
const VinylModel = this.models.Vinyl;
// 2. Check to see if the vinyl already exists or not.
// If it exists, then we'll perform an UPDATE. If not,
// we perform a CREATE.
const exists: boolean = await this.exists(vinyl.vinylId);
// 3. VinylMap create a JSON object that the Sequelize
// Vinyl model needs in order to save it to the DB.
// Check it out:
// https://github.com/stemmlerjs/white-label/blob/master/src/catalog/mappers/VinylMap.ts
const rawVinyl: any = VinylMap.toPersistence(vinyl);
...
}
}
VinylMap (changes vinyl to different formats)
Take a look at the VinylMap
. This class is singularly responsible for performing transformations on the Vinyl
class from the database all the way to DTO.
import { Mapper } from "../../core/infra/Mapper";
import { Vinyl } from "../domain/vinyl";
import { UniqueEntityID } from "../../core/domain/UniqueEntityID";
import { ArtistMap } from "./ArtistMap";
import { AlbumMap } from "./AlbumMap";
import { TraderId } from "../../trading/domain/traderId";
export class VinylMap extends Mapper<Vinyl> {
public static toDomain (raw: any): Vinyl {
const vinylOrError = Vinyl.create({
traderId: TraderId.create(raw.trader_id),
artist: ArtistMap.toDomain(raw.Artist),
album: AlbumMap.toDomain(raw.Album)
}, new UniqueEntityID(raw.vinyl_id));
vinylOrError.isFailure ? console.log(vinylOrError) : '';
return vinylOrError.isSuccess ? vinylOrError.getValue() : null;
}
public static toPersistence (vinyl: Vinyl): any {
return {
vinyl_id: vinyl.id.toString(),
artist_id: vinyl.artist.artistId.id.toString(),
album_id: vinyl.album.id.toString(),
notes: vinyl.vinylNotes.value
}
}
public static toDTO (vinyl): VinylDTO {
return {
vinyl_id: vinyl.id.toString()
trader_id: vinyl.traderId.id.tostring(),
artist: ArtistMap.toDTO(vinyl.artist),
album: AlbumMap.toDTO(vinyl.album)
}
}
}
Back to the save()
method in VinylRepo
.
export class VinylRepo implements IVinylRepo {
...
public async save (vinyl: Vinyl): Promise<Vinyl> {
const VinylModel = this.models.Vinyl;
const exists: boolean = await this.exists(vinyl.vinylId);
const rawVinyl: any = VinylMap.toPersistence(vinyl);
try {
// 4. Delegate saving an artist (if it doesn't exist)
// to the artistRepo.
// We know that we need to do this before we save Vinyl because
// vinyl relies on it through the 1-to-1 relationship
await this.artistRepo.save(vinyl.artist);
// 5. Also delegate saving the album (if it doesn't exist)
// to the albumRepo class. Vinyl also relies on this to exist
// first.
await this.albumRepo.save(vinyl.album);
if (!exists) {
// 6. If vinyl doesn't yet exist, then we'll CREATE it.
await VinylModel.create(rawVinyl);
} else {
// 7. If it does exist, then we'll UPDATE it.
await VinylModel.update(rawVinyl);
}
} catch (err) {
// 8. If anything fails, we'll do a manual rollback.
this.rollbackSave(vinyl);
}
return vinyl;
}
}
You can be sure that the AlbumRepo
and ArtistRepo
are following a similar algorithm to the one outlined here in VinylRepo
.
AlbumRepo (delegated the responsibility of persisting Albums
)
I've included the entire file here in case you want to peruse, but pay attention to the save()
method.
import { Repo } from "../../core/infra/Repo";
import { Album } from "../domain/album";
import { AlbumId } from "../domain/albumId";
import { AlbumMap } from "../mappers/AlbumMap";
import { IGenresRepo } from "./genresRepo";
import { Genre } from "../domain/genre";
export interface IAlbumRepo extends Repo<Album> {
findAlbumByAlbumId (albumId: AlbumId | string): Promise<Album>;
removeAlbumById (albumId: AlbumId | string): Promise<Album>;
}
export class AlbumRepo implements IAlbumRepo {
private models: any;
private genresRepo: IGenresRepo
constructor (models: any, genresRepo: IGenresRepo) {
this.models = models;
this.genresRepo = genresRepo;
}
private createBaseQuery (): any {
const { models } = this;
return {
where: {},
include: [
{ model: models.Genre, as: 'AlbumGenres', required: false }
]
}
}
public async findAlbumByAlbumId (albumId: AlbumId | string): Promise<Album> {
const AlbumModel = this.models.Album;
const query = this.createBaseQuery();
query['album_id'] = (
albumId instanceof AlbumId ? (<AlbumId>albumId).id.toValue() : albumId
);
const album = await AlbumModel.findOne(query);
if (!!album === false) {
return null;
}
return AlbumMap.toDomain(album);
}
public async exists (albumId: AlbumId | string): Promise<boolean> {
const AlbumModel = this.models.Album;
const query = this.createBaseQuery();
query['album_id'] = (
albumId instanceof AlbumId ? (<AlbumId>albumId).id.toValue() : albumId
);
const album = await AlbumModel.findOne(query);
return !!album === true;
}
public removeAlbumById (albumId: AlbumId | string): Promise<Album> {
const AlbumModel = this.models.Artist;
return AlbumModel.destroy({
where: {
artist_id: albumId instanceof AlbumId
? (<AlbumId>albumId).id.toValue()
: albumId
}
})
}
public async rollbackSave (album: Album): Promise<any> {
const AlbumModel = this.models.Album;
await this.genresRepo.removeByGenreIds(album.genres.map((g) => g.genreId));
await AlbumModel.destroy({
where: {
album_id: album.id.toString()
}
})
}
private async setAlbumGenres (sequelizeAlbumModel: any, genres: Genre[]): Promise<any[]> {
if (!!sequelizeAlbumModel === false || genres.length === 0) return;
return sequelizeAlbumModel.setGenres(genres.map((d) => d.genreId.id.toString()));
}
public async save (album: Album): Promise<Album> {
const AlbumModel = this.models.Album;
const exists: boolean = await this.exists(album.albumId);
const rawAlbum: any = AlbumMap.toPersistence(album);
let sequelizeAlbumModel;
try {
await this.genresRepo.saveCollection(album.genres);
if (!exists) {
sequelizeAlbumModel = await AlbumModel.create(rawAlbum);
} else {
sequelizeAlbumModel = await AlbumModel.update(rawAlbum);
}
await this.setAlbumGenres(sequelizeAlbumModel, album.genres);
} catch (err) {
this.rollbackSave(album);
}
return album;
}
}
The algorithm is pretty much the same with one small difference to how we save Album
Genres
with setAlbumGenres()
.
Sequelize Associations
In Sequelize, we have the ability to set associations.
When we define our models, we can do things like this:
Album.belongsToMany(models.Genre,
{ as: 'AlbumGenres', through: models.TagAlbumGenre, foreignKey: 'genre_id'}
);
The belongsToMany
association to Genres
adds a setGenres()
method to the sequelize Album
instance, making it easier to set the current Genres
for an Album
.
Rollbacks
The last thing to talk about are rolling back transactions.
Some C# popularized the Unit Of Work pattern.
However, for us to roll that ourselves would mean that we would have to pass a sequelize transaction through all of our repos and tie the execution of our use case to a single database transaction.
In theory, it sounds beautful. Implementing it is a little bit of a nightmare.
I've chosen to manually apply rollbacks because it's much simpler for now.
For example, in AlbumRepo
, this is what it looks like to rollback an Album
.
export class AlbumRepo implements IAlbumRepo {
public async rollbackSave (album: Album): Promise<any> {
const AlbumModel = this.models.Album;
await this.genresRepo.removeByGenreIds(album.genres.map((g) => g.genreId));
await AlbumModel.destroy({
where: {
album_id: album.id.toString()
}
})
}
}
I like to consider that a pretty pragmatic approach, although if it seems right for you, you could give Unit of Work a try.
Takeaways
We'll conclude this article here. If you stuck around this long, you're a real trooper you deserve all the success in domain modeling possible.
To recap, here's what we covered;
- Entities and value objects are clustered together into aggregates
- An "aggregate" is a cluster of associated objects that we treat as a unit for the purpose of data changes."
- The boundary is how far fully consistuted entities are in the aggregate.
- With all fully consistuted entities inside of the aggregate, it's possible for the aggregate root to enforce all invariants within the aggregate when it's state changes.
- An aggregate must always be returned fully constited from persistance. That constraint requires us to think hard about performance constraints and what's really necessary to fully pull from the database.
-
Our aggregate design goals are to:
- Provide enough info to enforce model invariants within a boundary
- Execute use cases
- Ensure decent database performance
- Provide enough info to transform a Domain Entity to a DTO
- Repositories are responsible for handling all of the complex aggregate persistence logic.
- Mappers are used to map aggregates to the format required for saving it in our persistence technology.
Discussion
Liked this? Sing it loud and proud 👨🎤.
Stay in touch!
Enjoying so far? Join 15000+ Software Essentialists getting my posts delivered straight to your inbox each week. I won't spam ya. 🖖
View more in Domain-Driven Design
You may also enjoy...
A few more related articles




Want to be notified when new content comes out?
Join 15000+ other Software Essentialists learning how to master The Essentials of software design and architecture.
14 Comments
Commenting has been disabled for now. To ask questions and discuss this post, join the community.
Great article, thanks for writing this up! I've taken a bit of a different approach to aggregate and domain model design using TypeORM (although due to limitations in TypeORM I may end up switching back to Sequelize which it appears you're using under the hood?). This domain model design is inspired by Vaughn Vernon's "Implementing Domain Driven Design":
Using this approach I don't need to use a custom repository that needs to be aware of how I persist my aggregates (can just use the built-in default repository from TypeORM), but this falls apart with soft deletes. Of course querying can get a bit more complex as far as repositories go. I recognize this violates the single responsibility principle because the domain model is also the ORM entity, but it seems like a potentially nice tradeoff and all the persistence logic is at least abstracted into decorators.
Most important for me, whether it be for aggregates or flat domain models, is that the domain model encapsulates my business logic (except in cases where a domain service is more appropriate). I'm personally not a fan of the active record pattern that Sequelize forces on you because your business logic ends up all over the place (not just theoretically, but from years of experience across various teams). You can compensate for that through abstraction and mapping, but unless you're working with an entire team of well-disciplined senior devs you're gonna spend a lot of time enforcing standards.
Also regarding the Unit of Work pattern - this is another reason I am trying TypeORM in place of Sequelize. It provides an entity manager which allows you to avoid passing your transaction around. Of course it does have some limitations and it seems you don't have the ability to explicitly pass around a transaction.
Why does Vinyl has a 1-1 relationship with Artist. An Artist couldn't have multiple Vinyls?
Let's assume I have an aggregate User roughly like below,
Here, Phone and Email are ValueObjects and Address is an Entity
The class Email is also similar to Phone.
Now, once the update phone request is received in the controller, the request is forwarded to the User Service layer and the service will look roughly like this,
Here each time the user requests for updating the phone number, I am fetching the user data from the RDBMS and doing all the validations for all the fields which are already validated and then calling the method User.update(). So, here are my questions:
Praveen! Thanks for taking the time to ask these questions.
1. I actually have some really good examples of how to handle updates against aggregates elegantly. If you give me a few hours, I can throw up a new post :) my response would be a bit too wordy for here.
2. That makes sense to me! `Address` could be it's own very small aggregate (which is good).
User can refer to the address aggregate root's `AddressId` instead of referring to the `Address` aggregate root itself.
You wouldn't want to the entire `Address` aggregate also inside of the `User` aggregate boundaries as well- it's good enough for `User` to hold a reference to the `Address` through the `AddressId`.
The implication here is that the Address aggregate is responsible entirely for everything within it's consistency boundaries (which means that User has no say in trying to maintain the Address invariants - that's a job for Address now that it's the aggregate root).
That also means that you need to create an `Address` first in order to create a `User` since the `User` requires an `AddressId`.
As for being able to update both `Address` and `User` in the same HTTP request: definitely possible.
It would lead me to design 2 separate application layer use cases: UpdateUser and UpdateAddress.
You can have a controller invoke both of these, performing 2 transactions, one for the Address aggregate, and one for the User aggregate. Each application layer use case will be responsible for a single transaction.
While it might be cleaner to associate updating one RESTful HTTP request to a particular aggregate, there's no problem with doing 'em both in one go.
I've done something similar to this before (and I can write about it in more detail if you like) where the request DTO can contain everything needed to create or update and address in addition to the user.
```typescript
interface UpdateUserAndAddressRequestDTO {
user: {
phone: '2162123212',
email: 'khalil@khalilstemmler.com',
},
address: {
street: '23 Blueville Road',
cityId: <some uuid>,
stateOrProvince: <some uuid>,
}
}
```
3. The aggregate root is the root entity, so deleting the aggregate root will cascade delete everything within the consistency boundary of the aggregate. If we want to delete something within the aggregate, we have to tell the aggregate root to mark it for deletion, then pass it off to the repo to delete anything marked for deletion. I can also provide a few examples of this as well!
So, to update a particular field in an aggregate, should i do all these steps everytime.
Without DDD, i would achieve this with a simple update Query.
That's correct. Using a layered architecture to isolate a domain model can be a lot of work.
But it's important to know when you need to use a domain model over a transaction script.
I first heard about Transaction Scripts from Fowler in his book on Enterprise Application Architecture. Transaction Scripts are how we normally build simple CRUD + MVC applications without a lot of business logic.
In this article, I said "A transaction script is a pattern documented by Martin Fowler. It's a procedural approach to handling business logic. It's also the simplest form of expressing domain logic that works really well in simple apps.
Transaction Scripts are an excellent choice for CRUD apps with little business logic.
The reason why a lot of CRUD apps that become more and more complex fail is because they fail to switch from the transaction script approach to a Domain Model."
A lot of things that are easy to fail to encapsulate (without duplication) without a domain model are:
Though it's makes sense to do business validation in domain objects, its turning difficult as we don't have repository reference. All your examples does just field validation but In most cases the validation has to be against database. So usecase is the place where this validations are easier.
Very nice article!
Great explanation! However, I still have an unanswered questions from this article and a lot of stackoverflow's answers.
Are repositories actually allowed to operate another repository directly?
Great article, Thanks
I have one question: since repository is the one communicating to persistence, what if my setup is that my persistence is instead consist of a third party REST service that returns JSON.
There will be a success and a fail case.
In a get/fetch/retrieve scenario, a success case will send me back a raw JSON data to be mapped toDomain() but a fail case might only consist of a "message" and status "code" JSON properties from that third party REST service.
As I've seen in your example, a get/fetch/retrieve method in repository returns the entity, but what if it fails in the persistence? Should the repo just throw Error() so that the use case layer is aware that something went wrong in the infra layer?
Thanks in advance.
Your aggregate is too large. And this code smell is very visible since you added other "private" repos for Artist and Genre. Artists can have many discs. The Artist is an aggregate by itself. Try to design smaller aggregates.
This was a lovely read. Thank you!!
I have one question if we see the application use-case layer for
Is it fine to call the value object or entity directly in application layer or this should be called via aggregate ?
Though it seems fine as inward dependency still I am confused should we expose domain layer things directly to application layer?
It looks to me like the rollback would delete an artist and genres that are used by other vinyls. Is this true?