Skip to content
aipatterns.dev

Code smellDispensables

Data Class

A class holds fields and accessors but no behavior, while other classes reach in to do the work that belongs to it.

Also known as
Anemic model

Signs

  • A class has only fields, getters and setters.
  • Public mutable fields or a setter for every field, including ones that should never change after creation.
  • Collections are exposed directly, so callers push and splice them.
  • Other classes contain methods that read three or four fields of the data class and compute something from them.
  • The same calculation over the class's fields appears in several services.

Why it happens

The class started as a record for transfer or persistence, and behavior grew in the services that use it. Some architectures encourage this by putting all logic in "service" classes. ORMs and serializers that need public setters push in the same direction.

A Data Class is not always wrong. The smell is the combination: passive data here, and code elsewhere that envies it.

Example

A warehouse Shipment exposes everything, and the services do the thinking.

shipment.ts
class Shipment {
  id = "";
  status: "packing" | "shipped" | "delivered" = "packing";
  parcels: { weightKg: number }[] = [];
  shippedAt?: Date;
}

class ShippingService {
  ship(s: Shipment): void {
    if (s.status !== "packing" || s.parcels.length === 0) throw new Error("cannot ship");
    s.status = "shipped";
    s.shippedAt = new Date();
  }
  totalWeight(s: Shipment): number {
    return s.parcels.reduce((sum, p) => sum + p.weightKg, 0);
  }
}

// elsewhere: s.parcels.push(...) and s.status = "delivered" with no checks

Move the rules into the class and close the fields:

shipment.ts
type Status = "packing" | "shipped" | "delivered";

class Shipment {
  private _status: Status = "packing";
  private _shippedAt?: Date;
  private readonly _parcels: { weightKg: number }[] = [];

  constructor(readonly id: string) {}

  get status(): Status { return this._status; }
  get parcels(): readonly { weightKg: number }[] { return this._parcels; }

  addParcel(weightKg: number): void {
    if (this._status !== "packing") throw new Error("shipment is closed");
    this._parcels.push({ weightKg });
  }

  ship(now = new Date()): void {
    if (this._status !== "packing" || this._parcels.length === 0) throw new Error("cannot ship");
    this._status = "shipped";
    this._shippedAt = now;
  }

  totalWeight(): number {
    return this._parcels.reduce((sum, p) => sum + p.weightKg, 0);
  }
}

Treatment

Payoff

  • Invariants are enforced in one place instead of trusted to every caller.
  • Duplicate calculations in services collapse into one method.
  • The class's API shows what you can do with it, not only what it stores.

When to ignore

  • It is a data transfer object, API payload, event or database row whose job is to carry data across a boundary. Keep it plain, and put behavior in the domain type on the other side.
  • It is an immutable record (readonly fields, TypeScript type, Java record, Python frozen dataclass) with no invariants to protect.
  • In functional style, data and functions are separate by design. Group the functions in one module next to the type.

Agent checklist

  • You confirmed the class is a domain object, not a DTO, event or persistence row that must stay plain.
  • Each moved method uses mostly the data class's fields, not the service's.
  • Every caller that mutated fields directly now goes through a method, and the type checker confirms no direct writes remain.
  • Serialization, ORM mapping and test fixtures still work with the new private fields.
  • Tests cover the invariants the new methods enforce.

Treatments

See also

Original writing and examples. Topic coverage follows Refactoring.Guru, which explains this topic with its own illustrations.