This is part of the Domain-Driven Design w/ TypeScript & Node.js course. Check it out if you liked this post.

Also from the Domain-Driven Design with TypeScript series.

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 } 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 ; } ... 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 ) { } 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) a Trader

belongs to (1-1) a Vinyl has a (1-1) Artist

has a (1-1) Vinyl belongs to (1-1) an Album

belongs to (1-1) an Album has many (1-m) Genres

has many (1-m) 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.

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 catalog addVinyl : add new existing vinyl createNewVinyl : create new vinyl getRecommendations : get recommendations based on vinyl currently in catalog getAllVinyl : get all vinyl in catalog getVinylByVinylId : get particular vinyl in catalog removeVinyl : remove particular vinyl in catalog searchCatalogForVinyl : search for vinyl in catalog updateVinyl : update vinyl in catalog

Marketplace Use cases on Vinyl in the public marketplace searchMarketplaceForVinyl : search the marketplace for vinyl getRecommendationsByVinylId : 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:

function createVinyl ( data ) : void { ... } function updateVinyl ( vinylId : string , data ) : void { ... } function updateVinyl ( vinylId : string , data ) : Vinyl { ... }

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.

function getVinylById ( vinylId : string ) : Vinyl { } function getVinylById ( vinylId : string ) : Vinyl { const vinyl = this . vinylRepo . getVinyl ( vinylId ) ; await updateVinyl ( 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 genres

contains text values of new genres ids: 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

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 ) { } private async getArtist ( request : AddVinylToCatalogUseCaseRequestDTO ) : Promise < Result < Artist >> { } private async getAlbum ( request : AddVinylToCatalogUseCaseRequestDTO , artist : Artist ) : Promise < Result < Album >> { } 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 ( ) ; 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 . map ( ( genreId ) => GenreId . create ( new UniqueEntityID ( genreId ) ) ) ) ) ) . concat ( ( ( ParseUtils . parseObject ( artistGenres ) as Result < GenresRequestDTO > ) . getValue ( ) . new ) . 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 ( ) ; 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.

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 > { const VinylModel = this . models . Vinyl ; const exists : boolean = await this . exists ( vinyl . vinylId ) ; 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 { 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 ; } }

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;