Skip to content

06. Class and Schema Design — draw the floor plan before writing walls

~12 min read. If the drawing is fuzzy, the code and tables wobble.

Built on the ELI5 in 00-eli5.md. The floor plan — classes and links before code — connects object design to table design.


1) Class diagrams tell you where each responsibility should live

A class diagram is pre-code thinking, not decoration. See. You are deciding which room owns which facts and which rules. That choice decides how expensive future change becomes. Take a tiny checkout flow. A customer buys 2 notebooks at ₹120 each and 1 pen at ₹35. Subtotal becomes ₹275. Those numbers must sit in the right class.

┌────────────────┐     1     *    ┌────────────────┐
│ Cart           │───────────────▶│ CartItem       │
├────────────────┤                ├────────────────┤
│ cartId         │                │ productId      │
│ customerId     │                │ quantity       │
│ items[]        │                │ unitPricePaise │
│ subtotal()     │                │ lineTotal()    │
└────────────────┘                └────────────────┘
Now ask one sharp question: why should each class change? If the answer has two reasons, split it. Simple, no? Bad draft:
class CheckoutManager {
    List<CartItem> items;
    void applyCoupon(String code) { }
    void chargeCard() { }
    void saveOrder() { }
    void sendConfirmation() { }
}
This mixes pricing, payment, persistence, and messaging in one place. So what to do? Split state from coordination. Better draft:
class Cart {
    private final List<CartItem> items;
    int subtotalPaise() { return 27500; }
}
class PricingService {
    int discountPaise(Cart cart, String couponCode) { return 5000; }
}
class OrderRepository {
    void save(Order order) { }
}
Notice the shape. Cart carries facts, PricingService applies policy, and OrderRepository stores aggregates. That is a useful floor plan. Quick checklist: - Entity: identity and lifecycle matter. - Value object: small immutable bundle like Money. - Service: coordinates rules across classes. - Repository: loads and saves aggregates.

2) Relationships and cardinality are meaning, not just arrows

Many learners memorize arrow names and stop there. That is not enough. Relationships tell you ownership, lifetime, and coupling. Cardinality tells you scale.

Association:  ┌──────────┐────────────▶┌──────────┐
              │ Teacher  │  teaches    │ Course   │
              └──────────┘             └──────────┘
Aggregation:  ┌──────────┐◇───────────▶┌──────────┐
              │ Team     │             │ Player   │
              └──────────┘             └──────────┘
Composition:  ┌──────────┐◆───────────▶┌──────────┐
              │ Order    │             │ Item     │
              └──────────┘             └──────────┘
Inheritance:        ┌──────────────┐
                    │ PaymentMethod│
                    └──────▲───────┘
                 ┌─────────┴─────────┐
                 │                   │
           ┌────────────┐      ┌───────────┐
           │ CardPay    │      │ UpiPay    │
           └────────────┘      └───────────┘
Association means both classes may live independently. Aggregation means the whole refers to parts that can survive alone. Composition means child lifetime depends on the parent. Inheritance means “is-a”, so use it only when substitution is safe. See this code-level difference.
class Order {
    private final List<OrderItem> items;
}
interface TaxCalculator {
    int taxPaise(Order order);
}
class CheckoutService {
    private final TaxCalculator taxCalculator;
}
OrderItem is composition. TaxCalculator is composition-friendly behavior injection. Why not subclass CheckoutService for every tax rule? Because the hallway should stay stable while policy changes. Now add multiplicity.
┌──────────────┐ 1      * ┌──────────────┐
│ Customer     │─────────▶│ Subscription │
└──────────────┘          └──────┬───────┘
                                  │ 1
                                  ▼ *
                           ┌──────────────┐
                           │ Invoice      │
                           └──────────────┘

Suppose one customer can hold 3 subscriptions, and each subscription creates 12 invoices yearly. With 8,000 customers, you can reach 24,000 subscriptions and 288,000 invoices. See how a tiny 1:* note starts affecting storage, pagination, and indexing?

Many-to-many also deserves honesty.

┌──────────────┐ 1     * ┌──────────────┐ *     1 ┌──────────────┐
│ Cart         │────────▶│ CartItem     │◀────────│ Product      │
└──────────────┘         └──────────────┘         └──────────────┘

CartItem is not just glue. It carries quantity, locked price, and discount snapshot. Simple, no?

class CartItem {
  productId: string;
  quantity: number;
  lockedUnitPricePaise: number;
}

3) Derive database schema from class intent without losing meaning

Now move from objects to tables. Do it one rule at a time. Map identity, ownership, and multiplicity carefully. See.

Start with this class view.

Customer 1 ──▶ * Order 1 ──▶ * OrderItem
Product  1 ──▶ * OrderItem
Order    1 ──▶ 1 ShippingAddress

A clean first schema can look like this.

CREATE TABLE customers (
  customer_id BIGINT PRIMARY KEY,
  email VARCHAR(255) NOT NULL UNIQUE
);
CREATE TABLE orders (
  order_id BIGINT PRIMARY KEY,
  customer_id BIGINT NOT NULL,
  total_paise BIGINT NOT NULL,
  ship_city VARCHAR(80) NOT NULL,
  ship_pin_code VARCHAR(10) NOT NULL,
  FOREIGN KEY (customer_id) REFERENCES customers(customer_id)
);
CREATE TABLE order_items (
  order_id BIGINT NOT NULL,
  line_no INT NOT NULL,
  product_id BIGINT NOT NULL,
  quantity INT NOT NULL,
  unit_price_paise BIGINT NOT NULL,
  PRIMARY KEY (order_id, line_no),
  FOREIGN KEY (order_id) REFERENCES orders(order_id)
);

Notice the signal. order_items uses the parent key plus line_no, so uniqueness lives inside one order. No extra surrogate key is compulsory here.

Take a number example. If daily traffic creates 25,000 orders and each order averages 4 items, you store 100,000 order-item rows daily. In 180 days, that becomes 18 million rows. So what to do? Create indexes early for known access paths.

CREATE INDEX idx_orders_customer_id ON orders(customer_id);
CREATE INDEX idx_order_items_product_id ON order_items(product_id);

Value objects need one more decision. Embed ShippingAddress when it belongs only to one order snapshot. Normalize it when many aggregates genuinely reuse the same record.

Many-to-many becomes a join table, especially when the relation carries facts.

CREATE TABLE enrollments (
  student_id BIGINT NOT NULL,
  course_id BIGINT NOT NULL,
  enrolled_at TIMESTAMP NOT NULL,
  status VARCHAR(20) NOT NULL,
  PRIMARY KEY (student_id, course_id)
);

The class model should preserve meaning, and the schema should preserve constraints. When both tell the same story, maintenance becomes calmer.


Where this lives in the wild

  • A Swiggy backend engineer maps Restaurant, MenuSection, and MenuItem so lunch-hour ordering stays consistent.
  • A Razorpay Java engineer designs Payment, Refund, and LedgerEntry relations so money movement remains auditable.
  • An Amazon catalog SDE decides when product variation needs inheritance, and when composition keeps the model safer.
  • A PhonePe service engineer turns wallet classes into tables with unique keys that block duplicate debits.
  • A Zoho CRM platform developer separates Lead, Contact, and Account so schema changes stay local.

Pause and recall

  1. When does composition communicate more truth than association?
  2. Why can a many-to-many relation deserve its own class?
  3. What does 1:N cardinality hint about indexing and pagination?
  4. When would you embed an address instead of normalizing it?

Interview Q&A

Why composition not association for Order and OrderItem?

Because the child has no meaningful life outside the parent order. Composition makes deletion rules and invariant boundaries explicit. Common wrong answer to avoid: “Both are just links, so either arrow is fine.”

Why composition not inheritance for Cart and CartItem?

Because an item is part of a cart, not a specialized cart. Inheritance would fake an “is-a” relation that simply does not exist. Common wrong answer to avoid: “Inheritance gives reuse, so it must be better.”

Why explicit Enrollment not direct many-to-many between Student and Course?

Because the relationship carries data like status and timestamp. An explicit class gives those facts names, validation, and lifecycle rules. Common wrong answer to avoid: “Join tables are only database details, not design details.”

Why embedded address not separate Address table for every case?

Because reuse and lifecycle decide the answer, not purity slogans. If an address belongs only to one order snapshot, embedding is simpler. Common wrong answer to avoid: “Normalization is always superior, so always split tables.”


Apply now (5 min)

Exercise. Take a movie ticket booking flow. List 6 nouns, convert 4 into classes, mark one association, one composition, and one 1:N relation. Then write the first three table names with primary keys.

Sketch from memory. Draw Customer, Booking, SeatHold, and Show. Add multiplicities beside every relationship, then explain why SeatHold should not be a primitive field list.


Bridge. The floor plan is clear. Next, define the hallway shapes other systems can trust. → 07-api-contract-design.md