← Back to the section

When you write a new method, a question comes up: which class should it go in? One developer puts it in a service, another in a domain object, a third in a separate helper. Without a guideline everyone decides on their own, and a year later the code becomes hard to read.

GRASP (General Responsibility Assignment Software Patterns) is nine principles from Craig Larman's book "Applying UML and Patterns" that give concrete criteria: look at these signs — and you'll know who to hand the responsibility to.

SOLID describes what a class should be like overall. GRASP answers a more specific question: who is responsible for what right now.

Information Expert — logic where the data is

The most common question in design: "in the service or in the domain object?"

Without a guideline the developer puts everything in the service, because that's more familiar. As a result the domain object becomes a passive container of getters, and the service becomes a long script that pulls those getters and computes the result itself. This situation is called an anemic model: the data is in one place, the logic in another.

Information Expert says: give the responsibility to whoever already has the data to carry it out.

Compute the order total — who has the data? The order itself: it knows the lines and prices.

class Order {

  private readonly lines: OrderLine[];

  total(): Money {
    return this.lines
      .map(line => line.subtotal())
      .reduce((acc, subtotal) => acc.add(subtotal), Money.ZERO);
  }
}

Now the method OrderService.calculateTotal(order), which drags the data through getters, simply isn't needed. When the line structure changes, only Order changes, not the service as well.

The rule works on two levels: for small computations (the method belongs to the object with the data) and for large operations (if you need external dependencies — a database, a queue — then that's already the level of a service, not an aggregate).

Creator — who creates the object

Before: the controller creates an OrderLine and puts it into the order through a setter. Or a factory method in the service creates the line, knowing nothing about the order's rules.

The pain: the invariant "you can't add a line to a completed order" has no one to enforce it — it's smeared across every creation point.

Creator says: object A is created by whoever contains A, aggregates A, or owns the data to initialize it.

OrderLine is created by Order — it contains the lines and knows when they can be added:

class Order {

  addLine(product: Product, quantity: number): void {
    this.ensureStatus('CREATED');
    this.lines.push(OrderLine.of(product.id, product.price, quantity));
  }
}

The controller no longer knows about OrderLine and can't bypass the status check. The invariant is protected in one place.

Controller — who coordinates the operation

The GRASP Controller is not a framework's HTTP controller.

The transport layer (HTTP, message queue) is responsible for one thing only: accept the request, pass it on, return the response. If business logic seeps into it, that logic becomes unreachable from another transport and untestable without spinning up the HTTP stack.

The GRASP Controller is a dedicated object that coordinates the execution of a single operation: it gathers the needed data, calls the domain, saves the result.

// Transport layer — only parses the request and delegates
@Controller('v1/orders')
export class OrderController {

  constructor(private readonly handler: CancelOrderHandler) {}

  @Post(':orderId/cancel')
  async cancel(
    @Param('orderId') orderId: string,
    @Body() req: CancelOrderRequest,
  ): Promise<void> {
    await this.handler.handle(new CancelOrderCommand(orderId, req.reason));
  }
}

// GRASP Controller — coordinates the operation
@Injectable()
export class CancelOrderHandler {

  constructor(private readonly orders: OrderRepository) {}

  @Transactional()
  async handle(command: CancelOrderCommand): Promise<void> {
    const order = await this.orders.findById(command.orderId);
    order.cancel(command.reason);
    await this.orders.save(order);
  }
}

When there's no such role in the system, coordination sprawls across the transport layer — and the same operation can't be invoked from a message queue or from a test without HTTP.

Coupling is anything that will force a class to change along with another one: a field type, a direct method call, inheritance.

The more links, the more painful any change: you edit one class, and a wave spreads across ten.

Low Coupling says: among equal options, pick the one that creates fewer links.

In practice this means: depend on an interface, not on a concrete class; communicate through events, not direct calls; don't make one object depend on half the system.

A good diagnosis of high coupling is a constructor with seven parameters: it means the class knows about too many things.

High Cohesion — one class, one topic

Low cohesion looks like this: an OrderService class of eight hundred lines, in which order creation, cancellation, Excel export, and statistics computation all live. These four topics have nothing to do with each other — you change one and risk touching another.

High Cohesion says: a class does closely related things; unrelated responsibilities go in different classes.

CancelOrderHandler is cohesive: everything in it serves one operation. When the cancellation rules change, you know exactly which file to open.

The same principle applies to modules: an orders module with everything related to the order is more cohesive than a services module with services from every domain at once.

Low Coupling and High Cohesion are a pair: minimum links between classes, maximum cohesion within each.

Polymorphism — behavior by type without conditionals

A switch on the customer type, an if on the payment method, conditional chains on the export format — every new variant requires diving into the same code and adding a new branch.

Polymorphism says: behavior that depends on type goes to polymorphism, not to conditional chains.

interface DiscountPolicy {
  apply(price: Money): Money;
}

class NoDiscount implements DiscountPolicy {
  apply(price: Money): Money { return price; }
}

class VipDiscount implements DiscountPolicy {
  apply(price: Money): Money { return price.multiply(0.8); }
}

A new discount type is a new class, no edits to existing code.

For a closed set of variants (their number won't grow), union types are an equal alternative. The compiler checks branch exhaustiveness. Polymorphism through an interface wins when the set is open — plugins, new providers, new pricing plans.

A detailed discount example is in the SOLID article. Ready-made structures for polymorphism are in the GoF catalog (Strategy, Template Method).

Pure Fabrication — an invented class for the sake of cleanliness

The domain has no concept of a "repository." The customer doesn't say "put the order into the OrderRepository." But if you don't invent such a class, the database-handling logic will smear across the domain objects, and the domain will become coupled to a specific storage.

Pure Fabrication says: a class that represents no real domain concept is acceptable if it improves cohesion and reduces coupling.

OrderRepository, OrderMapper, Clock, UuidGenerator — these are all fabrications for the sake of a clean design. The principle legitimizes them: it's not a violation but a deliberate architectural decision.

Two classes know about each other — which means a change in one drags a change in the other.

Indirection says: introduce a mediator, and both will know only about it, not about each other.

Examples of mediators: an event bus between publisher and subscribers, a port interface between the service and an external system, a dispatcher between transport and handlers.

There's a downside too. The classic line: "there is no problem that can't be solved by an extra level of indirection — except the problem of too many levels." A mediator is justified when it breaks a link that should be broken. An interface with a single implementation inside one small module is overhead without benefit.

Protected Variations — a stable interface around a point of change

Everything changes: providers, formats, external APIs, requirements. The question is whether those changes ripple in a wave across the whole codebase or get absorbed in one place.

Protected Variations says: find the likely points of change and shield them behind a stable interface.

The payment provider will change — which means the code works with a PaymentGateway interface, not with a specific provider's SDK. The external event format evolves — which means there's a transformation layer between it and the domain. A third-party API is unstable — which means it's isolated behind an adapter.

OCP and DIP from SOLID are concrete techniques for implementing this principle. Hexagonal architecture is its systematic application across the whole service.

The downside is YAGNI: it's worth protecting likely changes, not every conceivable one. An interface for the sake of an interface is overhead too.

In short

  • Information Expert: logic lives where the data is. If a computation needs only an object's fields — the method belongs to it, not to a service.
  • Creator: an object is created by whoever owns it or has the data to initialize it. An aggregate creates its own internal entities.
  • Controller: a dedicated coordinator of an operation, separate from the transport layer — then the logic can be invoked from any transport and any test.
  • Low Coupling: fewer links between classes — changes stay local. Depend on an interface, not on an implementation.
  • High Cohesion: a class does one thing, and everything in it is interrelated. Unrelated things go in different classes.
  • Polymorphism: a switch on type is a signal to replace it with an interface and implementations. For closed sets — union types.
  • Pure Fabrication: a class outside the domain (Repository, Mapper, Clock) is not a violation but a deliberate fabrication for the sake of cleanliness.
  • Indirection: a mediator (interface, bus, dispatcher) breaks a direct link. But there shouldn't be more mediators than needed.
  • Protected Variations: a stable interface around a point of change absorbs it locally instead of spreading it in a wave.
  • SOLID by example — principles about what the class you handed the responsibility to should be like.
  • GoF patterns — ready-made constructs for Polymorphism, Indirection, and Pure Fabrication.
  • Tactical DDD patterns — Information Expert and Creator in the context of aggregates.