StackPractices
intermediate By Mathias Paulenko

Composite Entity Pattern

Map a coarse-grained entity to multiple database tables by composing dependent objects, so the whole aggregate loads and saves as one unit.

Overview

The Composite Entity Pattern maps one coarse-grained entity object to several fine-grained database tables by composing dependent objects into it. Instead of giving every dependent object its own remote interface or repository, the composite entity bundles them so the whole graph loads, changes, and persists in a single operation.

The pattern comes from EJB 2.x entity beans, where every remote call was expensive and fine-grained entities meant a network round trip per field. Today it survives as a persistence-mapping strategy: an aggregate root like Order owns value objects such as line items, shipping address, and payment details that mean nothing on their own.

Mermaid flowchart TD diagram

When to Use

Use the Composite Entity Pattern when:

  • An aggregate root contains dependent objects that should be persisted together
  • Dependent objects have no meaning outside their parent entity
  • You need one load/save boundary around the whole object graph
  • You want to maintain referential integrity across related tables
  • Fine-grained remote or per-table calls would add real overhead

For a simpler alternative when each object maps to exactly one table, see the Data Mapper Pattern.

When to Avoid

  • Dependent objects are shared across multiple parents — shared children are independent entities
  • Child objects need standalone CRUD (e.g., admins edit line items outside the order)
  • The object graph is deeply nested and loading it eagerly hurts performance
  • Microservice boundaries would be violated by a coarse-grained aggregate
  • A document database already gives you embedded documents — the pattern is built in

Solution

Python

A runnable example with sqlite3: the mapper loads the order from three tables and saves it back in one call.

from dataclasses import dataclass, field
from typing import List, Optional

@dataclass
class LineItem:
    product_id: str
    quantity: int
    unit_price: float

    @property
    def total(self) -> float:
        return self.quantity * self.unit_price

@dataclass
class ShippingAddress:
    street: str
    city: str
    country: str
    postal_code: str

@dataclass
class PaymentDetails:
    method: str
    transaction_id: str
    amount: float

@dataclass
class Order:
    order_id: Optional[str] = None
    customer_id: str = ""
    line_items: List[LineItem] = field(default_factory=list)
    shipping_address: Optional[ShippingAddress] = None
    payment: Optional[PaymentDetails] = None

    @property
    def total(self) -> float:
        return sum(item.total for item in self.line_items)


class OrderMapper:
    """Composite entity mapper loading from multiple tables"""
    def __init__(self, conn):
        self._conn = conn

    def find_by_id(self, order_id: str) -> Optional[Order]:
        # Load parent order
        row = self._conn.execute(
            "SELECT order_id, customer_id FROM orders WHERE order_id = ?",
            (order_id,)
        ).fetchone()
        if not row:
            return None

        order = Order(order_id=row["order_id"], customer_id=row["customer_id"])

        # Load dependent line items
        for item_row in self._conn.execute(
            "SELECT product_id, quantity, unit_price FROM line_items WHERE order_id = ?",
            (order_id,)
        ):
            order.line_items.append(LineItem(
                product_id=item_row["product_id"],
                quantity=item_row["quantity"],
                unit_price=item_row["unit_price"]
            ))

        # Load shipping address
        addr_row = self._conn.execute(
            "SELECT street, city, country, postal_code FROM shipping_addresses WHERE order_id = ?",
            (order_id,)
        ).fetchone()
        if addr_row:
            order.shipping_address = ShippingAddress(
                street=addr_row["street"],
                city=addr_row["city"],
                country=addr_row["country"],
                postal_code=addr_row["postal_code"]
            )

        return order

    def save(self, order: Order):
        # One transaction keeps the three tables consistent
        with self._conn:
            self._conn.execute(
                "INSERT OR REPLACE INTO orders (order_id, customer_id) VALUES (?, ?)",
                (order.order_id, order.customer_id)
            )

            # Delete old line items, re-insert
            self._conn.execute("DELETE FROM line_items WHERE order_id = ?", (order.order_id,))
            for item in order.line_items:
                self._conn.execute(
                    "INSERT INTO line_items (order_id, product_id, quantity, unit_price) VALUES (?, ?, ?, ?)",
                    (order.order_id, item.product_id, item.quantity, item.unit_price)
                )

            if order.shipping_address:
                self._conn.execute(
                    """INSERT OR REPLACE INTO shipping_addresses
                       (order_id, street, city, country, postal_code)
                       VALUES (?, ?, ?, ?, ?)""",
                    (order.order_id, order.shipping_address.street,
                     order.shipping_address.city, order.shipping_address.country,
                     order.shipping_address.postal_code)
                )


# Usage
import sqlite3
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
conn.execute("CREATE TABLE orders (order_id TEXT PRIMARY KEY, customer_id TEXT)")
conn.execute("""CREATE TABLE line_items (
    order_id TEXT, product_id TEXT, quantity INTEGER, unit_price REAL
)""")
conn.execute("""CREATE TABLE shipping_addresses (
    order_id TEXT PRIMARY KEY, street TEXT, city TEXT, country TEXT, postal_code TEXT
)""")

mapper = OrderMapper(conn)
order = Order(
    order_id="ORD-001",
    customer_id="CUST-001",
    line_items=[
        LineItem("PROD-1", 2, 29.99),
        LineItem("PROD-2", 1, 49.99),
    ],
    shipping_address=ShippingAddress("123 Main St", "Springfield", "USA", "62701")
)

mapper.save(order)
loaded = mapper.find_by_id("ORD-001")
print(f"Order total: ${loaded.total:.2f}")  # Order total: $109.97

Java

The same mapper with JDBC, now including save() so the full cycle works.

import java.sql.*;
import java.util.*;

public class LineItem {
    private final String productId;
    private final int quantity;
    private final double unitPrice;

    public LineItem(String productId, int quantity, double unitPrice) {
        this.productId = productId; this.quantity = quantity; this.unitPrice = unitPrice;
    }
    public double getTotal() { return quantity * unitPrice; }
    public String getProductId() { return productId; }
    public int getQuantity() { return quantity; }
    public double getUnitPrice() { return unitPrice; }
}

public class ShippingAddress {
    private final String street, city, country, postalCode;
    public ShippingAddress(String street, String city, String country, String postalCode) {
        this.street = street; this.city = city; this.country = country; this.postalCode = postalCode;
    }
    public String getStreet() { return street; }
    public String getCity() { return city; }
    public String getCountry() { return country; }
    public String getPostalCode() { return postalCode; }
}

public class Order {
    private final String orderId;
    private final String customerId;
    private final List<LineItem> lineItems = new ArrayList<>();
    private ShippingAddress shippingAddress;

    public Order(String orderId, String customerId) {
        this.orderId = orderId; this.customerId = customerId;
    }
    public String getOrderId() { return orderId; }
    public String getCustomerId() { return customerId; }
    public List<LineItem> getLineItems() { return lineItems; }
    public ShippingAddress getShippingAddress() { return shippingAddress; }
    public void setShippingAddress(ShippingAddress addr) { this.shippingAddress = addr; }
    public double getTotal() { return lineItems.stream().mapToDouble(LineItem::getTotal).sum(); }
}

class OrderMapper {
    private final Connection conn;
    public OrderMapper(Connection conn) { this.conn = conn; }

    public Order findById(String orderId) throws SQLException {
        try (PreparedStatement stmt = conn.prepareStatement(
                "SELECT customer_id FROM orders WHERE order_id = ?")) {
            stmt.setString(1, orderId);
            try (ResultSet rs = stmt.executeQuery()) {
                if (!rs.next()) return null;
                Order order = new Order(orderId, rs.getString("customer_id"));

                // Load line items
                try (PreparedStatement itemStmt = conn.prepareStatement(
                        "SELECT product_id, quantity, unit_price FROM line_items WHERE order_id = ?")) {
                    itemStmt.setString(1, orderId);
                    try (ResultSet items = itemStmt.executeQuery()) {
                        while (items.next()) {
                            order.getLineItems().add(new LineItem(
                                items.getString("product_id"),
                                items.getInt("quantity"),
                                items.getDouble("unit_price")
                            ));
                        }
                    }
                }

                // Load shipping
                try (PreparedStatement addrStmt = conn.prepareStatement(
                        "SELECT street, city, country, postal_code FROM shipping_addresses WHERE order_id = ?")) {
                    addrStmt.setString(1, orderId);
                    try (ResultSet addr = addrStmt.executeQuery()) {
                        if (addr.next()) {
                            order.setShippingAddress(new ShippingAddress(
                                addr.getString("street"), addr.getString("city"),
                                addr.getString("country"), addr.getString("postal_code")
                            ));
                        }
                    }
                }
                return order;
            }
        }
    }

    public void save(Order order) throws SQLException {
        boolean auto = conn.getAutoCommit();
        conn.setAutoCommit(false);  // one transaction across all three tables
        try {
            try (PreparedStatement stmt = conn.prepareStatement(
                    "INSERT OR REPLACE INTO orders (order_id, customer_id) VALUES (?, ?)")) {
                stmt.setString(1, order.getOrderId());
                stmt.setString(2, order.getCustomerId());
                stmt.executeUpdate();
            }

            try (PreparedStatement del = conn.prepareStatement(
                    "DELETE FROM line_items WHERE order_id = ?")) {
                del.setString(1, order.getOrderId());
                del.executeUpdate();
            }
            try (PreparedStatement ins = conn.prepareStatement(
                    "INSERT INTO line_items (order_id, product_id, quantity, unit_price) VALUES (?, ?, ?, ?)")) {
                for (LineItem item : order.getLineItems()) {
                    ins.setString(1, order.getOrderId());
                    ins.setString(2, item.getProductId());
                    ins.setInt(3, item.getQuantity());
                    ins.setDouble(4, item.getUnitPrice());
                    ins.executeUpdate();
                }
            }

            if (order.getShippingAddress() != null) {
                try (PreparedStatement addr = conn.prepareStatement(
                        "INSERT OR REPLACE INTO shipping_addresses (order_id, street, city, country, postal_code) VALUES (?, ?, ?, ?, ?)")) {
                    ShippingAddress a = order.getShippingAddress();
                    addr.setString(1, order.getOrderId());
                    addr.setString(2, a.getStreet());
                    addr.setString(3, a.getCity());
                    addr.setString(4, a.getCountry());
                    addr.setString(5, a.getPostalCode());
                    addr.executeUpdate();
                }
            }
            conn.commit();
        } catch (SQLException e) {
            conn.rollback();
            throw e;
        } finally {
            conn.setAutoCommit(auto);
        }
    }
}

// Usage
Connection conn = DriverManager.getConnection("jdbc:sqlite:orders.db");
conn.createStatement().execute("CREATE TABLE IF NOT EXISTS orders (order_id TEXT PRIMARY KEY, customer_id TEXT)");
conn.createStatement().execute("CREATE TABLE IF NOT EXISTS line_items (order_id TEXT, product_id TEXT, quantity INTEGER, unit_price REAL)");
conn.createStatement().execute("CREATE TABLE IF NOT EXISTS shipping_addresses (order_id TEXT PRIMARY KEY, street TEXT, city TEXT, country TEXT, postal_code TEXT)");

OrderMapper mapper = new OrderMapper(conn);
Order order = new Order("ORD-001", "CUST-001");
order.getLineItems().add(new LineItem("PROD-1", 2, 29.99));
order.setShippingAddress(new ShippingAddress("123 Main St", "Springfield", "USA", "62701"));

mapper.save(order);
Order loaded = mapper.findById("ORD-001");
System.out.println("Order total: $" + loaded.getTotal());

JavaScript

Same structure with an async mapper, including save().

class LineItem {
  constructor(productId, quantity, unitPrice) {
    this.productId = productId;
    this.quantity = quantity;
    this.unitPrice = unitPrice;
  }

  get total() {
    return this.quantity * this.unitPrice;
  }
}

class ShippingAddress {
  constructor(street, city, country, postalCode) {
    this.street = street;
    this.city = city;
    this.country = country;
    this.postalCode = postalCode;
  }
}

class Order {
  constructor(orderId, customerId) {
    this.orderId = orderId;
    this.customerId = customerId;
    this.lineItems = [];
    this.shippingAddress = null;
  }

  get total() {
    return this.lineItems.reduce((sum, item) => sum + item.total, 0);
  }
}

class OrderMapper {
  constructor(db) {
    this.db = db;
  }

  async findById(orderId) {
    const row = await this.db.get('SELECT customer_id FROM orders WHERE order_id = ?', orderId);
    if (!row) return null;

    const order = new Order(orderId, row.customer_id);

    const items = await this.db.all('SELECT product_id, quantity, unit_price FROM line_items WHERE order_id = ?', orderId);
    for (const item of items) {
      order.lineItems.push(new LineItem(item.product_id, item.quantity, item.unit_price));
    }

    const addr = await this.db.get('SELECT street, city, country, postal_code FROM shipping_addresses WHERE order_id = ?', orderId);
    if (addr) {
      order.shippingAddress = new ShippingAddress(addr.street, addr.city, addr.country, addr.postal_code);
    }

    return order;
  }

  async save(order) {
    // Wrap the three writes in one transaction
    await this.db.exec('BEGIN');
    try {
      await this.db.run(
        'INSERT OR REPLACE INTO orders (order_id, customer_id) VALUES (?, ?)',
        order.orderId, order.customerId
      );
      await this.db.run('DELETE FROM line_items WHERE order_id = ?', order.orderId);
      for (const item of order.lineItems) {
        await this.db.run(
          'INSERT INTO line_items (order_id, product_id, quantity, unit_price) VALUES (?, ?, ?, ?)',
          order.orderId, item.productId, item.quantity, item.unitPrice
        );
      }
      if (order.shippingAddress) {
        await this.db.run(
          'INSERT OR REPLACE INTO shipping_addresses (order_id, street, city, country, postal_code) VALUES (?, ?, ?, ?, ?)',
          order.orderId, order.shippingAddress.street, order.shippingAddress.city,
          order.shippingAddress.country, order.shippingAddress.postalCode
        );
      }
      await this.db.exec('COMMIT');
    } catch (err) {
      await this.db.exec('ROLLBACK');
      throw err;
    }
  }
}

// Usage (with an async sqlite wrapper such as `sqlite`/`better-sqlite3` adapter)
// const mapper = new OrderMapper(db);
// await mapper.save(order);
// const loaded = await mapper.findById('ORD-001');
// console.log(loaded.total);

Explanation

The Composite Entity Pattern treats a group of related objects as one persistence unit:

  • Composite Entity (Order): the aggregate root that owns dependent objects
  • Dependent Objects (LineItem, ShippingAddress, PaymentDetails): objects that exist only inside the parent — no standalone repository, no standalone identity
  • Mapper: coordinates loading and saving across every table the aggregate touches

The insight that makes the pattern work is ownership. A LineItem has no global identity; “line 3 of order ORD-001” is its whole identity. That means the aggregate decides when dependents are created, changed, and deleted — never the caller.

Choosing the boundary

The hard part is not the code; it’s deciding what belongs inside the composite. A useful test: if deleting the parent should delete the object, it belongs inside. LineItem passes — an order’s line items die with the order. Customer fails — deleting an order must not delete the customer, so customer_id stays a foreign key reference, not a composed object.

Keep the boundary small. Every extra dependent makes findById slower (one more query) and save() longer (one more write in the transaction). If a child collection can grow unbounded — say, thousands of line items — loading it eagerly every time becomes a problem, and that child probably deserves its own entity.

Transactions are part of the pattern

Notice the save() implementations above wrap every write in one transaction (with conn, setAutoCommit(false), BEGIN/COMMIT). That’s not optional polish — writing orders and line_items in separate commits means a crash in between leaves an order without its items. The whole point of a composite is that it persists as a unit; partial writes break the contract.

The N+1 temptation

The examples run three sequential queries: orders, then line items, then addresses. That’s fine for one order, but if you list 100 orders this way you get 201 queries. Two fixes, depending on your stack:

  • Join in the mapper: one query with LEFT JOIN line_items and group the rows in code — one round trip, slightly more mapping code.
  • Batch by ID list: WHERE order_id IN (?, ?, ?) once per table, then distribute rows to orders — 3 queries total regardless of order count.

Don’t reach for a full ORM just to solve this; both fixes fit in a small mapper.

The Lifecycle of a Composite

A composite entity has a different lifecycle than a flat record, and getting it right is most of the work:

  1. Create: instantiate the aggregate root and attach dependents in memory. Nothing touches the database yet — the composite exists only as a consistent in-memory graph.
  2. Persist: save() writes the parent row first (it owns the key), then every dependent row under one transaction. On insert, the parent’s generated ID flows into each child’s foreign key.
  3. Load: findById() rehydrates the graph from its tables and returns a fully-formed aggregate — never a partial object with lazy hooks the caller has to know about.
  4. Modify: callers mutate the aggregate in memory and call save() again. The mapper doesn’t diff; it deletes the old children and re-inserts the current ones. Crude, but correct and easy to reason about.
  5. Delete: deleting the parent deletes all dependents. With ON DELETE CASCADE foreign keys this is one statement; without them, delete children first, then the parent, in the same transaction.

Two rules follow from this lifecycle. First, the aggregate is always loaded and saved whole — there is no “load just the line items” path in a composite (use a projection or a separate entity for that). Second, invariants are checked before save(), not inside it: an order with a negative total or zero line items should never reach the mapper.

Composite Entity vs. the Alternatives

It’s easy to confuse this pattern with its neighbors in the persistence-pattern family:

  • Data Mapper solves the general problem of keeping objects and schema independent. Composite Entity is a specialization: a mapper that knows one object actually spans several tables. If every class maps to one table, you don’t need it.
  • Active Record puts persistence on the entity itself. A composite entity could use active records internally, but then every dependent knows about the database and the “one transaction” guarantee gets much harder — which is why composites almost always pair with a mapper or repository.
  • Unit of Work tracks changes across many objects and commits them together. Composite Entity is narrower: one object graph, one save. If you’re coordinating multiple aggregates in one transaction, that’s Unit of Work’s job on top.
  • Aggregate Pattern (DDD) is the same idea at the domain level — composite entity is essentially the persistence-side implementation of an aggregate.

The practical takeaway: if you already think in aggregates, this pattern is how you store them. If you don’t, adopting it just to save a join will feel like ceremony — match the pattern to a real coarse-grained entity.

Testing the Mapper

Because the mapper owns real SQL, it deserves real tests — against a real database, not mocks:

import sqlite3
import pytest

@pytest.fixture
def mapper():
    conn = sqlite3.connect(":memory:")
    conn.row_factory = sqlite3.Row
    conn.execute("CREATE TABLE orders (order_id TEXT PRIMARY KEY, customer_id TEXT)")
    conn.execute("CREATE TABLE line_items (order_id TEXT, product_id TEXT, quantity INTEGER, unit_price REAL)")
    conn.execute("CREATE TABLE shipping_addresses (order_id TEXT PRIMARY KEY, street TEXT, city TEXT, country TEXT, postal_code TEXT)")
    return OrderMapper(conn)

def test_save_and_load_round_trip(mapper):
    order = Order(order_id="ORD-1", customer_id="C-1",
                  line_items=[LineItem("P1", 2, 10.0)])
    mapper.save(order)
    loaded = mapper.find_by_id("ORD-1")
    assert loaded.total == 20.0
    assert len(loaded.line_items) == 1

def test_removed_line_item_is_orphan_deleted(mapper):
    order = Order(order_id="ORD-2", customer_id="C-1",
                  line_items=[LineItem("P1", 1, 10.0), LineItem("P2", 1, 5.0)])
    mapper.save(order)
    order.line_items.pop()          # remove one item
    mapper.save(order)
    loaded = mapper.find_by_id("ORD-2")
    assert len(loaded.line_items) == 1  # orphan row actually deleted

The second test is the one that matters: orphan deletion is where composite mappers most often go wrong in production. A unit test against in-memory SQLite catches it in milliseconds.

Designing the Schema

The tables behind a composite entity should encode the ownership, not just hold data. For the order example:

CREATE TABLE orders (
    order_id    TEXT PRIMARY KEY,
    customer_id TEXT NOT NULL,
    created_at  TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE line_items (
    order_id    TEXT NOT NULL REFERENCES orders(order_id) ON DELETE CASCADE,
    line_no     INTEGER NOT NULL,
    product_id  TEXT NOT NULL,
    quantity    INTEGER NOT NULL CHECK (quantity > 0),
    unit_price  REAL NOT NULL CHECK (unit_price >= 0),
    PRIMARY KEY (order_id, line_no)
);

CREATE TABLE shipping_addresses (
    order_id    TEXT PRIMARY KEY REFERENCES orders(order_id) ON DELETE CASCADE,
    street      TEXT NOT NULL,
    city        TEXT NOT NULL,
    country     TEXT NOT NULL,
    postal_code TEXT NOT NULL
);

Three details do real work here:

  • Composite key (order_id, line_no) on line_items: the dependent’s identity is local to its parent, exactly as the pattern intends — no synthetic UUID per line, no way for two orders to share a row.
  • ON DELETE CASCADE: deleting the parent order removes children at the database level, which makes “delete the aggregate” one statement and guarantees orphans can’t survive even if application code forgets them.
  • Check constraints on the child table: invariants like positive quantities live as close to the data as possible, backing up the validation the aggregate does in memory.

If you later add payments or order_events, the same shape applies: parent-keyed table, cascade delete, local identity. The schema and the aggregate stay in lockstep — which is what makes the composite mental model hold up under real queries.

Variants

VariantMapping StrategyUse Case
Table per classEach dependent has its own tableComplex queries on child data
Single tableAll data in one denormalized tableSimple reads, no joins needed
JSON columnDependents stored as JSONFlexible schema, document databases
Embedded valueFlattened into parent columnsSimple value objects

When JSON columns win

PostgreSQL jsonb and MySQL JSON columns let you store line_items and shipping_address inside the orders row itself. You trade queryability for simplicity: one table, one write, no orphan cleanup. Choose it when you never query inside the dependents (no “find all orders containing product X”) and the documents stay small. Once you need to filter or index inside the JSON, go back to real tables.

What Works

  • Make dependent objects immutable. Changes should go through the aggregate root.
  • Enforce invariants at the aggregate level. The composite entity validates the whole (e.g., order total can’t be negative).
  • Save the whole aggregate in one transaction. Partial writes corrupt the boundary.
  • Delete-then-insert for child collections. Simpler than diffing rows; correct inside a transaction.
  • Keep nesting to 2–3 levels. Deeper graphs become hard to load and reason about.
  • Consider JSON columns for flexibility. Modern databases index and validate structured data.

Common Mistakes

  • Exposing dependent objects directly. Clients should interact with the aggregate root, never mutate a LineItem the mapper returned.
  • Allowing standalone persistence of dependents. A LineItemRepository next to OrderRepository breaks the boundary — pick one owner.
  • Loading the entire graph for list views. Project just order_id, customer_id, total for lists; load dependents on demand.
  • Sharing dependent objects between parents. Each composite owns its children; shared children are entities, not dependents.
  • Ignoring orphan deletion. Removing a line item in memory must delete its row — the delete-then-insert strategy handles this for free.
  • No transaction around save(). Without one, a mid-write crash leaves the parent and children inconsistent.

Real-World Examples

JPA @Embeddable

JPA’s @Embeddable annotation marks dependent objects stored within their parent’s table; @Embedded composes them into the entity. @ElementCollection handles the child-table variant for collections like line items — the Java example above is the manual version of what Hibernate generates.

DDD Aggregate Roots

Domain-Driven Design formalized the same idea as Aggregates: a cluster of entities and value objects with one root, one transactional consistency boundary, and outside references allowed only to the root.

MongoDB Embedded Documents

MongoDB makes the pattern native: embedded documents keep dependents inside the parent document, so one findOne returns the whole aggregate and one write updates it atomically.

Further Reading

Frequently Asked Questions

What's the difference between Composite Entity and Composite Pattern?

Composite Pattern (GoF) is about tree structures where leaf and composite nodes share one interface — a UI widget tree, a file system. Composite Entity is about persistence mapping: one aggregate root owning dependent objects across tables. They share a name and nothing else.

Can dependent objects have their own IDs?

Yes, but only locally. A line item can be #3 inside order ORD-001; it should never have a globally unique key that other objects reference. If something else needs to point at it, it's an entity, not a dependent.

Should I always cascade deletes?

For true dependents, yes — they can't outlive their parent. The moment a child might survive the parent's deletion, it stops being a dependent and should be modeled as its own entity with a foreign key.

How big can a composite entity get before it's a problem?

Watch the child collections, not the object count. A handful of fixed dependents (address, payment) is fine forever. A collection that grows with usage — line items, events, attachments — eventually forces you to paginate or lazy-load it, which means it wants its own aggregate. A rough ceiling: if a dependent collection can exceed a few hundred rows, keep it outside.

Table-per-dependent or JSON column — how do I choose?

Ask whether you ever query inside the dependent. Need "all orders containing PROD-1"? Real table. Only ever read and write the whole blob? JSON column saves you a join and the orphan cleanup. Teams often start with JSON and migrate to tables once reporting needs appear.