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

This article is part of the upcoming DDD + TypeScript course. Check it.
Last updated Aug 16th, 2019
In this article, you'll learn how identify the aggregate root and encapsulate a boundary around related entities. You'll also learn how to structure and persist aggregates using the Sequelize ORM on White Label, the open-source Vinyl Trading app.

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) a Trader
  • Vinyl has a (1-1) Artist
  • Vinyl belongs to (1-1) an Album
  • 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.

Vinyl Aggregate - Domain-Driven Design TypeScript DDD

  • 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 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

Domain-Driven Design Aggregate Design Trade Offs - DDD TypeScript

At this point with our boundaries on Vinyl, think about how many tables we need to join in order to retrieve a single Vinyl.

Domain-Driven Design Aggregate Database View - DDD TypeScript

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:

White Label - Domain Driven Design TypeScript DDD App - Wireframe #8

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 COMMANDs.

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.

White Label - Domain Driven Design TypeScript DDD App - Wireframe #1

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.

White Label - Domain Driven Design TypeScript DDD App - Wireframe #2

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.

White Label - Domain Driven Design TypeScript DDD App - Wireframe #3

Add new vinyl - Fill in addition New Artist details

When they're completed with that, they can add the Album details.

White Label - Domain Driven Design TypeScript DDD App - Wireframe #4

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.

White Label - Domain Driven Design TypeScript DDD App - Wireframe #6

Add new vinyl - Fill in album details.

Lastly, they can add any relevant information about their copy of the record before hitting submit.

White Label - Domain Driven Design TypeScript DDD App - Wireframe #7

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.

White Label - Domain Driven Design TypeScript DDD App - Wireframe #8

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
  • 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

// 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 👨‍🎤.


14 Comments

Commenting has been disabled for now. To ask questions and discuss this post, join the community.

Jordan
4 years ago

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":


@Entity()
export class Order extends AbstractBaseEntity {
  @ManyToOne(() => Customer, (customer: Customer) => customer.orders)
  customer: Customer

  // eager load aggregate relationships (things like this could be further abstracted into special aggregate decorators)
  @OneToMany(() => OrderLineItem, (lineItem: OrderLineItem) => lineItem.order, {
    cascade: true,
    eager: true
  })
  lineItems: OrderLineItem[];

  public create({
    customer,
    lineItems
  }:
  {
    customer: Customer;
    lineItems: OrderLineItem[]
  }): void {
    // assert invariants
    if (this.id) {
      throw new Error('create method may not be called on an existing entity');
    }

    // update the entity state
    this.id = shortid.generate();
    this.orderLineItems = lineItems;
  }

  public addLineItems({ lineItems }: { lineItems: OrderLineItem[] }) {
    // make some assertions about order status, etc.. to validate this action can occor
    // update the state
    this.orderLineItems = [...this.orderLineItems, ...lineItems];
  }
}


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.

Jordan
4 years ago

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.

Emilio Lopez
4 years ago

Why does Vinyl has a 1-1 relationship with Artist. An Artist couldn't have multiple Vinyls?

Praveen
4 years ago

Let's assume I have an aggregate User roughly like below,

class User extends AggregateRoot {

id: number;
phone: Phone;
email: Email;
address: Address;

private constructor(id, phone, email, address){
    //setting the values
}

public static create(props) {

    return new User({...props});
}

public static update(props) {

    return new User({...props});
}

}

Here, Phone and Email are ValueObjects and Address is an Entity

class Phone extends ValueObject {

phNumber: string;

private constructor( ph ) {

    phNumber = ph;
}

public static create(ph){

    //do validations
    return new Phone(ph);
}
}

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,

public updatePhone( updatePhNoDto ) {

const userEntity = userRepository.getUser(updatePhNoDto.userId);

const userModel = User.update({
    id: updatePhNoDto.userId,
    phone: Phone.create(userEntity.phone),
    email: Email.create(userEntity.email),
    address: Address.create(userEntity.address)
});

userRepository.updateUser(userModel)
}

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:

  1. Not sure if the above is the right way since I am validating the stuffs that I have already validated and possibly an unnecessary DB call. So, please suggest me on the best practices to deal with the situations like this where a single or only a few fields are being requested to be updated.
  2. A user can update his Address independent of his other information. So, should the Address entity be an Aggregate Root by itself? If yes, how should it be handled if both UserInfo and Address are requested to be updated in a single http-request?
  3. What is the role of the Aggregate Root in deletion? How should that be modeled inside it?


Khalil Stemmler
4 years ago

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!



vicky
4 years ago

So, to update a particular field in an aggregate, should i do all these steps everytime.


  1. Fetch the entity from the database table.
  2. Convert/map the entity to domain model.
  3. Update the domain model with the requested field.
  4. Convert the domain model to database entity.
  5. Persist in database.

Without DDD, i would achieve this with a simple update Query.

Khalil Stemmler
4 years ago

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:


  • validation logic
  • invariant rules
  • domain events
  • use cases
  • and business logic


Vicky
4 years ago

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.

Chris
3 years ago

Very nice article!

Setya W
3 years ago

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?

Alireza
3 years ago

Great article, Thanks

alven
3 years ago

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.

Lucian
2 years ago

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.

Abdulmalik
2 years ago

This was a lovely read. Thank you!!

AJ
a year ago

I have one question if we see the application use-case layer for

 AddVinylToCatalogUseCase


Is it fine to call the value object or entity directly in application layer or this should be called via aggregate ?


return Artist.create({ 
        name: ArtistName.create(artistNameOrId).getValue(), 
        genres: await this.getGenresFromDTO(artistGenres as string)
      })


Though it seems fine as inward dependency still I am confused should we expose domain layer things directly to application layer?

Alex
10 months ago

It looks to me like the rollback would delete an artist and genres that are used by other vinyls. Is this true?


Stay in touch!



About the author

Khalil Stemmler,
Software Essentialist ⚡

I'm Khalil. I turn code-first developers into confident crafters without having to buy, read & digest hundreds of complex programming books. Using Software Essentialism, my philosophy of software design, I coach developers through boredom, impostor syndrome, and a lack of direction to master software design and architecture. Mastery though, is not the end goal. It is merely a step towards your Inward Pull.



View more in Domain-Driven Design



You may also enjoy...

A few more related articles

How to Handle Updates on Aggregates - Domain-Driven Design w/ TypeScript
In this article, you'll learn approaches for handling aggregates on Aggregates in Domain-Driven Design.
Decoupling Logic with Domain Events [Guide] - Domain-Driven Design w/ TypeScript
In this article, we'll walk through the process of using Domain Events to clean up how we decouple complex domain logic across the...
Handling Collections in Aggregates (0-to-Many, Many-to-Many) - Domain-Driven Design w/ TypeScript
In this article, we discuss how we can use a few CQS principles to handle unbounded 0-to-many or many-to-many collections in aggre...
Challenges in Aggregate Design #1 - Domain-Driven Design w/ TypeScript
In this series, we answer common questions in aggregate design. Here's an article orignating from the question, "How can a domain ...

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.

Get updates