| Lesson 9 | Course Project, Part 5 |
| Objective | Model the vehicles queued at an intersection without mixing shared descriptions and live traffic state. |
The course project now needs an intersection model that records vehicles
waiting in each approach. This is where the preceding Flyweight lessons become
concrete. A shared VehicleType can describe stable properties,
while each queued VehicleEvent records occurrence-specific state.
The intersection owns queues and traffic-control decisions; the flyweight
factory owns canonical descriptions. Keeping those responsibilities separate
prevents the optimization from controlling the simulation.
Strings such as "north" and "car" are easy to mistype and make invalid states possible. Java enums provide a closed direction set, and records provide a compact immutable event value. The event refers to a shared vehicle type but retains its own identifier, arrival time, lane, and direction.
enum Direction { NORTH, SOUTH, EAST, WEST }
record VehicleEvent(
long id,
VehicleType type,
Direction direction,
int lane,
long arrivalTick
) {
VehicleEvent {
java.util.Objects.requireNonNull(type);
java.util.Objects.requireNonNull(direction);
if (lane < 0) throw new IllegalArgumentException("lane");
}
}
The record does not store current queue position because position follows from
the queue. It also does not mutate VehicleType. Two events can
share the same type and remain distinct through their event IDs.
final class VehicleIntersection {
private final java.util.EnumMap<Direction,
java.util.ArrayDeque<VehicleEvent>> queues =
new java.util.EnumMap<>(Direction.class);
VehicleIntersection() {
for (Direction direction : Direction.values()) {
queues.put(direction, new java.util.ArrayDeque<>());
}
}
void arrive(VehicleEvent event) {
queues.get(event.direction()).addLast(event);
}
int waiting(Direction direction) {
return queues.get(direction).size();
}
java.util.Optional<VehicleEvent> release(Direction direction) {
return java.util.Optional.ofNullable(
queues.get(direction).pollFirst());
}
}
EnumMap documents the closed key set and avoids four nearly
identical fields. ArrayDeque provides queue operations without
the node allocation of LinkedList. Returning
Optional makes an empty approach explicit. Production code may
release several vehicles during a green phase rather than clearing an entire
queue instantaneously.
Choosing the largest queue is a useful baseline, but it can starve a quieter direction when traffic continuously arrives elsewhere. The structural model should expose queue observations to a separate controller policy. That policy can consider waiting time, pedestrian phases, emergency priority, minimum green duration, yellow clearance, and fairness. A deterministic policy is easy to test with a fixed event sequence.
This separation resembles Bridge: the intersection abstraction can work with different control policies. A simple strategy may be a lambda or small class, so a full pattern hierarchy is unnecessary until multiple policies and independent evolution justify it. The key is that queue storage does not hide global scheduling decisions.
A deterministic simulation should advance from an explicit clock rather than wall-clock time. Process arrivals, signal changes, and departures in a defined order for each tick. If several threads produce events, collect them through a controlled boundary and apply them in sequence, or protect queues with a documented synchronization policy. An immutable flyweight does not make the mutable intersection queues thread safe.
These tests distinguish structural correctness from a visually plausible animation. They give the following exercise a concrete contract to implement and evaluate.
Collect queue length, oldest waiting time, departures per phase, and controller decisions for each direction. Flyweight metrics should separately report catalog entries, hits, and misses. Keeping these signals separate makes it possible to distinguish a scheduling defect from an object-sharing defect. Logs should reference event and type identifiers rather than dumping mutable object graphs.
Set explicit invariants: no event may appear in two queues, a released event cannot be released again, queue counts cannot be negative, and every accepted direction has a queue. Validate those invariants during development and in property-based tests with generated arrival sequences. Observability then supports both algorithm evaluation and structural maintenance. It also supplies evidence for comparing controller strategies under the same repeatable traffic workload. Preserve the event stream and configuration with the test result so performance and fairness comparisons remain reproducible after implementation changes.
The prior module introduced a vehicle factory. A Flyweight version can obtain canonical specifications from a repository and create a new context for each arrival. The repository should be injected into the factory rather than accessed through a hidden global singleton. Injection exposes the dependency, supports deterministic tests, and allows a scenario to use a fixed catalog.
The factory's contract should distinguish two operations: retrieving a shared
specification and creating a new simulated occurrence. Returning the same
mutable VehicleState twice would be a defect. Returning the same
immutable VehicleSpec twice is the intended optimization. Tests
should assert both behaviors.
An intersection model has several other structural boundaries. An Adapter can
translate a sensor feed into the simulation's arrival event. A Facade can
offer operations such as advanceOneTick() and
requestSignalChange() without exposing every queue and timing
object. A Decorator can add tracing to a signal controller. A Proxy can enforce
authorization around a remote control interface.
These components should not be combined merely to display many pattern names. Each pattern must protect a specific boundary. The Flyweight addresses duplicated immutable description. The signal-control algorithm belongs to a behavioral strategy introduced later. Queue data structures and timing rules remain domain concerns. Keeping those purposes separate prevents the structural layer from becoming a miscellaneous collection of wrappers.
Memory optimization is useful only if the simulation remains correct. Create tests for several vehicle types entering different lanes at the same time. Confirm that their specification references are shared where expected, while position and speed change independently. Exercise red, yellow, and green transitions, queue capacity, pedestrian phases, and vehicles that turn across opposing traffic.
For cloud-hosted simulations, include scenario identifiers and versions in persisted data. In-memory flyweight identity does not survive serialization or deployment. Persist the canonical key, then resolve it through the local repository when reconstructing the model.
Operational tests should record queue length, wait time, throughput, and specification-cache size. These measurements distinguish traffic-control quality from memory optimization. A change that reduces shared-object count but increases unsafe turns is unacceptable, while a signal algorithm that improves throughput may be valuable even if Flyweight has little effect at the course project's scale.
The same separation helps when the simulation is distributed. One service can own scenario state while another publishes sensor observations. An Adapter validates incoming messages, and the scenario service resolves a versioned vehicle key through its local specification repository. Messages should carry values and identifiers, not serialized assumptions about an object reference from another process.
Finally, preserve a simple non-flyweight implementation as a comparison. Run the same deterministic scenario against both designs and compare results, memory, and execution time. Equivalent outcomes demonstrate that optimization has not changed domain behavior. The measurements show whether the extra factory and context types are justified.
This comparison also creates a safe extension point for later lessons. A behavioral signal strategy can operate on the same intersection facade whether vehicle descriptions are shared or copied. Structural optimization remains an implementation choice behind a stable simulation contract.
Maintain that contract in both Java and C++ examples. Language-specific ownership and memory rules may differ, but vehicle creation, queue progression, and signal decisions should produce equivalent observable scenarios.
That equivalence keeps the pattern lesson focused on design rather than syntax.