GRASP · Controller
Controller
Controller means: a dedicated object receives each system operation — park, exit, pay — instead of a UI widget or a domain entity. Without that habit, the gate button’s onClick parks the car, picks a spot, creates a ticket, and charges a fee. Then a second gate appears, and an API, and a test. Nobody can park without Swing. Put the verb on a use-case object that UI, HTTP, and tests all call.
Who receives “park this car”?
A controller, in GRASP, is the object that handles a system operation: park, exit, pay. It sits at the application edge. It does not own tax rules. It does not draw buttons. It asks the experts and the lot to do the work.
Keep park() off Ticket. Ticket is a stay, not the application. Keep park() off the JFrame and off the HTTP handler’s anonymous lambda. A thin ParkingService or ParkVehicle use case is enough. The UI and the API both call it. See use cases: the verb on the whiteboard is the controller’s name.
A controller that contains allocation, pricing, persistence, and notifications is no longer a controller. It is a fat class that swallowed every collaborator’s job. Thin is the point: receive the verb, load what you need, ask domain objects, save, return. If you cannot name the use case, you are not ready to name the controller. Start from park / exit / pay.
Conversation, not a widget
Park flows through one use-case object
Gate UI / HTTP → ParkVehicle (controller)
│
├─ ParkingLot.allocate(vehicle)
├─ ParkingLot.enter(...) // Creator on the lot
└─ TicketRepository.save(ticket)
Ticket is not the controller.
JButton is not the controller.
ExitService is a second controller, for a second verb.Java: a thin use-case object receives park()
class ParkVehicle {
private final ParkingLot lot;
private final TicketRepository tickets;
ParkVehicle(ParkingLot lot, TicketRepository tickets) {
this.lot = lot;
this.tickets = tickets;
}
Ticket handle(Vehicle vehicle, Clock clock) {
ParkingSpot spot = lot.allocate(vehicle)
.orElseThrow(() -> new IllegalStateException("lot full"));
Ticket ticket = lot.enter(vehicle, spot, clock);
tickets.save(ticket);
return ticket;
}
}
class ExitVehicle {
private final TicketRepository tickets;
private final PricingStrategy pricing;
private final Notifier notifier;
Money handle(TicketId id, Clock clock) {
Ticket ticket = tickets.find(id).orElseThrow();
ticket.close(clock.instant());
Money due = pricing.fee(ticket, clock);
tickets.save(ticket);
notifier.ticketClosed(ticket, due);
return due;
}
}Two gates, one controller
North gate and south gate, plus an HTTP entry for booked arrivals, all call ParkVehicle.handle. They map buttons or JSON into Vehicle and Clock. They do not allocate spots. Tests call handle with a fake lot and never construct a JFrame. That reuse is why the controller exists.
If park logic stays in onClick, the second gate copies it. Then the API copies it again. Three formulas for “create a ticket” is how you get three bugs when GST changes — except GST should not have been in the controller anyway.
Thin means asks, not computes
If ParkVehicle computes GST, the expert was skipped. The controller asks PricingStrategy; it does not become PricingStrategy. Persistence is asked via TicketRepository. Allocation is asked via the lot. The controller’s file should read like a short script of the use case.
Putting the use case on the entity
Ticket.parkCar(lot, vehicle) turns one stay into the application facade. Other use cases then pile onto Ticket: pay, refund, export. The entity becomes a controller plus a God object. Receive park() next to the application boundary. Let Ticket close itself — that is the stay’s job — but do not let Ticket allocate floors.
// Wrong: the stay is now the application
class Ticket {
static Ticket parkCar(ParkingLot lot, Vehicle v, Clock clock) {
ParkingSpot spot = lot.findNearest(v);
Ticket t = new Ticket(...);
lot.spots().add(spot);
jdbc.save(t);
twilio.send(v.phone());
return t;
}
}
// Right: Ticket only knows how a stay ends
class Ticket {
void close(Instant exitedAt) {
if (this.exitedAt != null) throw new IllegalStateException("already closed");
this.exitedAt = exitedAt;
}
}A controller that does everyone’s job
ParkingService with park, exit, pay, refund, exportCsv, and sendDailyReport is an oversized service that borrowed the word “controller.” It handles every verb in one class. Split by system operation. ParkVehicle. ExitVehicle. Maybe PayTicket if payment is its own use case. Reports are a different application edge. GRASP Controller is one operation, not a fat class that owns the whole menu. That also keeps Single Responsibility honest at the application layer.
HTTP handlers stay dumb
In a Spring-shaped sketch, the RestController is not the GRASP controller. The HTTP class maps JSON to Vehicle, calls ParkVehicle.handle, maps Ticket to a response. If you skip the use-case class and put allocation in the RestController, you have the Swing problem again with annotations. Keep the HTTP type thin so a CLI and a test can park without a servlet.
class ParkingHttp {
private final ParkVehicle park;
private final ExitVehicle exit;
TicketResponse postEntry(VehicleRequest body) {
Ticket ticket = park.handle(body.toVehicle(), Clock.systemUTC());
return TicketResponse.from(ticket);
}
FeeResponse postExit(TicketId id) {
Money due = exit.handle(id, Clock.systemUTC());
return new FeeResponse(due);
}
}Pay can be its own operation
Some lots charge at exit. Some charge when the driver taps a kiosk later. If pay is a separate system operation, give it PayTicket. ExitVehicle then only closes the stay and returns the amount due — or ExitVehicle asks PayTicket if payment must happen in the same conversation. Do not grow ExitVehicle into refunds, coupons, and receipts because those verbs showed up in the same interview sentence. Each verb is a chance to keep a controller thin.
class PayTicket {
private final TicketRepository tickets;
private final PaymentGateway payments;
Receipt handle(TicketId id, Money amount) {
Ticket ticket = tickets.find(id).orElseThrow();
if (!ticket.isClosed()) throw new IllegalStateException("still parked");
PaymentId pid = payments.charge(amount, id);
ticket.markPaid(pid);
tickets.save(ticket);
return new Receipt(pid, amount);
}
}Tests call handle, not onClick
A good ParkVehicle test constructs an in-memory lot, an in-memory repository, a vehicle, and a frozen clock. It asserts a ticket was saved and a spot is occupied. It never constructs a button. If you cannot write that test without Swing or Spring, the controller is still stuck in a widget. Pull the use case until the test is a dozen lines.
Failure paths belong here too: lot full, ticket not found, already closed. The controller translates those domain errors for HTTP later. It should not catch SQLException because SQL should not have entered this class. That is Controller meeting Dependency Inversion on the same file.
Related
One operation, one failure vocabulary
ParkVehicle can fail because the lot is full or the vehicle is banned. ExitVehicle can fail because the ticket is missing or already closed. Mixing those errors into one ParkingService.handle(command) with a stringly-typed action is how controllers become dispatchers. Keep the types. The compiler then tells the HTTP layer which failures exist. That is a thin controller with a sharp edge, not a generic “do stuff” method.
class LotFull extends RuntimeException {}
class TicketNotFound extends RuntimeException {}
class AlreadyClosed extends RuntimeException {}
class ParkVehicle {
Ticket handle(Vehicle vehicle, Clock clock) {
return lot.allocate(vehicle)
.map(spot -> lot.enter(vehicle, spot, clock))
.orElseThrow(LotFull::new);
}
}
class ExitVehicle {
Money handle(TicketId id, Clock clock) {
Ticket ticket = tickets.find(id).orElseThrow(TicketNotFound::new);
if (ticket.isClosed()) throw new AlreadyClosed();
ticket.close(clock.instant());
Money due = pricing.fee(ticket, clock);
tickets.save(ticket);
return due;
}
}CLI, API, and button share handle()
A machine-coding main() that reads stdin should call ParkVehicle.handle just like HTTP does. If main() allocates spots itself, you have three controllers again. Keep main dumb: parse a line, call handle, print the ticket id. That is the whole point of receiving the system operation in one object.
public static void main(String[] args) {
ParkVehicle park = ParkingApp.park();
Scanner in = new Scanner(System.in);
while (in.hasNextLine()) {
Vehicle v = parse(in.nextLine());
Ticket t = park.handle(v, Clock.systemUTC());
System.out.println(t.id());
}
}Do not hide park inside a generic handle(Command)
A Command enum with PARK, EXIT, PAY and one service method that switches on it reintroduces the God object as a dispatcher. GRASP Controller wants one type per system operation, or at least one method that does not switch on the verb. The HTTP router already dispatched. Your domain does not need a second router.
Name the use case on the class
ParkVehicle, ExitVehicle, PayTicket read as verbs. ParkingManager does not. If you cannot fit the operation in the type name, you are collecting verbs again. Rename until the file title is the system operation. That rename is the controller habit made visible on disk.
Takeaway
Receive park, exit, and pay on thin use-case objects. The UI and HTTP map input; they do not allocate. Ticket is a stay, not the application. A service that owns every verb is an oversized application class, not a controller — split by system operation and keep GST out of the script.