Solving Persistence of DDD Objects with Prisma (SQL)

Introduction

Domain-Driven Design (DDD) is a software development methodology that emphasizes the importance of modeling business domains to create software that closely aligns with the needs of the organization. However, persisting DDD objects can present challenges for developers. In this article, we'll discuss some common problems when persisting DDD objects and explore how to solve them using Prisma, a powerful and flexible ORM for Node.js and TypeScript. We will use the following example models throughout the article:

class Customer {
  id: string;
  name: string;
  orders: Order[];
}

class Order {
  id: string;
  customerId: string;
  total: number;
  items: OrderItem[];
}

class OrderItem {
  productId: string;
  quantity: number;
  price: number;
}

Problem 1: Create VS Update

In DDD, aggregate or entity IDs are usually set upon object creation if they do not exist. This presents a problem: if the ID always exists, how do we know if we should use Create or Update when persisting the object? Using the wrong operation will result in an error. Consider the code below:

import { Customer } from './models/Customer';
import * as cuid from 'cuid';

class CustomerAggregate {
  private constructor(private _customer: Customer) {}

  static create(customer: Partial<Customer>): CustomerAggregate {
    const id = customer.id || cuid();
    const newCustomer = new Customer({ ...customer, id });
    return new CustomerAggregate(newCustomer);
  }

  get customer(): Customer {
    return this._customer;
  }
}
async function saveCustomer(customer: Customer) {
  if (customer.id) {
    await prisma.customer.update({ where: { id: customer.id }, data: customer });
  } else {
    await prisma.customer.create({ data: customer });
  }
}

if the Customer Object always has an ID then the else path will never be reached! Of course, there is a million hacky way to solve this like adding a flag to the domain object but we are looking for a clear separation between the Domain and the DB.

Solution: Using upsert with Prisma

Prisma provides an upsert feature, which allows developers to replace Create and Update with Save. This simplifies the persistence logic, as shown in the code below:

async function saveCustomer(customer: Customer) {
  await prisma.customer.upsert({
    where: { id: customer.id },
    update: customer,
    create: customer,
  });
}

One caveat to this is that the save method can get quite large as the aggregate increase in the number of relations and complexity. See example below

async save(propertyListing: PropertyListingAggregate): Promise<{ id: string }> {
    const { pricingOptions, images, propertyListingData } = propertyListingMapper.toDb(propertyListing);

     await this.prisma.propertyListings.upsert({
      where: {
        id: propertyListingData.id,
      },
      create: {
        ...propertyListingData,
        customGeneralInfo: Prisma.JsonNull,
        customFeaturesAndAmenities: Prisma.JsonNull,
        pricingOptions: {
          createMany: {
            data: pricingOptions,
          },
        },
        images: {
          createMany: {
            data: images,
          },
        },
      },
      update: {
        ...propertyListingData,
        customGeneralInfo: Prisma.JsonNull,
        customFeaturesAndAmenities: Prisma.JsonNull,
        pricingOptions: {
          deleteMany: {},
          createMany: {
            data: pricingOptions,
          },
        },
        images: {
          deleteMany: {},
          createMany: {
            data: images,
          },
        },
      },
    });

    return { id: propertyListingData.id };
  }

Problem 2: Value Objects Persisted on Another Table

Sometimes, we have DDD objects that should be value objects but, due to how they are persisted in the database, we need to assign them an ID. This goes against one of the fundamental ideas of DDD: that database concerns should not leak into the domain.

Solution: Transactions and Cascading Relations

To address this issue, we can use transactions in the Save method. This allows us to first delete all value objects and then create new ones through cascading relations. Consider the code example below:

async function saveOrder(order: Order) {
  await prisma.$transaction([
    prisma.orderItem.deleteMany({ where: { orderId: order.id } }),
    ...order.items.map((item) =>
      prisma.orderItem.create({
        data: { ...item, orderId: order.id },
      })
    ),
    prisma.order.upsert({
      where: { id: order.id },
      update: { total: order.total },
      create: { ...order, items: { create: order.items } },
    }),
  ]);
}

While this solution does increase the number of database operations, it prevents database concerns from leaking into the domain.

Summary and Conclusion

Persisting DDD objects can pose challenges for developers, but Prisma provides powerful tools to overcome these issues. In this article, we explored how to use the upsert feature to simplify the Create and Update operations and how to use transactions and cascading relations to handle value objects that are persisted on separate tables. By employing these techniques, developers can adhere to the principles of DDD while efficiently persisting their domain objects.