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 }));
}
}
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>();
}
}
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.
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.
Clicking "+ Create new artist [Artist name]" will open up more details for the user to fill out like the Genres
of this artist.
When they're completed with that, they can add the Album
details.
Again, if the album hasn't ever been added to the platform, they'll be required to fill in all the details manually.
Lastly, they can add any relevant information about their copy of the record before hitting submit.
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.
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