[AoNW] Testing Bounded Contexts: Making Architecture Fail Loudly

In Age of New Worlds, bounded contexts are not only a design idea. They are something I want the test suite to defend.

It is easy to draw clean diagrams early in a project. The hard part is keeping them true after months of adding features, fixing UI problems, wiring multiplayer, changing saves, and moving logic between client and server.

A boundary does not usually break dramatically. It breaks through one convenient import, one shortcut, one helper used from the wrong layer, one piece of presentation logic leaking into the domain.

So I try to make those mistakes fail loudly.

The Contexts I Care About

The project has several contexts that should stay separate:

aonw_core
├── shared commands, events, protocol, rules, primitives

lib/map
├── map data, geometry, loading, validation, rendering helpers

lib/game/domain
├── local game rules, reducers, state transitions

lib/game/application
├── use cases and ports

lib/game/infrastructure
├── persistence, local transport, system adapters

lib/game/presentation
├── Flutter UI, Flame renderer, Riverpod, view models

server/lib
├── authoritative command handling, projections, routes, persistence

They are allowed to talk, but not in every direction.

flowchart TB
    Core["aonw_core"]
    Map["Map Context"]
    Domain["Game Domain"]
    App["Application"]
    Infra["Infrastructure"]
    Presentation["Presentation"]
    Server["Server"]

    Core --> Domain
    Core --> Server
    Map --> Domain
    Domain --> App
    App --> Infra
    App --> Presentation
    Presentation --> App
    Server --> Core

The exact arrows can change, but the rule is stable: dependencies should point inward toward rules and contracts, not outward toward UI or implementation details.

Import Boundary Tests

The simplest boundary tests scan Dart files and reject forbidden imports.

For example, the game domain should not import Flutter, Flame, Riverpod, infrastructure, presentation, path_provider, or shared_preferences.

A simplified version looks like this:

test('game domain does not depend on outer game layers or UI frameworks', () {
  expect(
    _violations(
      roots: const ['lib/game/domain'],
      disallowed: const [
        _ImportRule.frameworks,
        _ImportRule.gameApplication,
        _ImportRule.gameInfrastructure,
        _ImportRule.gamePresentation,
      ],
    ),
    isEmpty,
  );
});

This is not a fancy test. It is just a guardrail.

But it protects an important architectural promise: domain code must remain ordinary Dart code. It should be testable without widgets, Flame components, providers, files, or platform APIs.

The server has a similar guard:

test('server package stays Dart-only', () {
  expect(
    _violations(
      roots: const ['lib', 'bin'],
      disallowedPrefixes: const [
        'package:flutter/',
        'package:flame/',
        'package:riverpod/',
      ],
    ),
    isEmpty,
  );
});

The server must not accidentally become a Flutter application. It is an authoritative Dart backend, and its dependencies should reflect that.

Time, Logging, and Other Hidden Leaks

Not every boundary violation is an import from the wrong folder.

Some are hidden dependencies on the outside world.

For example, the domain should not call DateTime.now() directly. If game logic reads wall-clock time by itself, tests become less deterministic and server replay becomes harder.

So the tests also check for text-level violations:

test('game domain does not read wall-clock time directly', () {
  expect(
    _textViolations(
      roots: const ['lib/game/domain'],
      disallowed: const [_TextRule.dateTimeNow],
    ),
    isEmpty,
  );
});

The same idea applies to logging. Game code should use the project’s logging abstraction instead of scattering debugPrint through the codebase.

These tests are small, but they protect determinism.

Contract Tests for Ports

Import guards tell me that contexts are not coupled in the wrong direction. Contract tests tell me that they still cooperate correctly.

The application layer talks to the outside world through ports:

GameRepository
CommandTransport
EventLog
SnapshotStore
Clock
IdGenerator

A repository implementation can use JSON files. A command transport can be local or networked. A clock can be real or fixed.

The application should not care.

That is why I test implementations through their port contracts. For example, JsonGameRepository is tested as a GameRepository, not as “some class that writes files”.

final repository = JsonGameRepository(
  savesDir: tempDir,
  clock: _FixedClock(_fixedNow),
  idGenerator: _SequenceIdGenerator(),
);

final saveId = await repository.create(
  const NewGameRequest(
    name: 'Contract Game',
    mapName: 'verdantia',
    mapSource: MapSource.asset,
  ),
);

final snapshot = await repository.load(saveId);

expect(snapshot.save.id, saveId);
expect(snapshot.save.savedAt, _fixedNow);

This protects the boundary between application and infrastructure.

The application depends on the contract. The infrastructure proves it fulfills the contract.

Command Transport Boundaries

CommandTransport is another important boundary because it sits between intent and state transition.

A command may come from the HUD, the Flame renderer, AI, hotseat mode, or the network. The application should dispatch it through the same shape.

A local transport test checks that dispatching does more than mutate memory:

final result = await transport.dispatch(
  saveId: save.id,
  currentState: GameState(
    units: [commander],
    activePlayerId: 'player_1',
    activePlayerCanAct: true,
  ),
  command: MoveUnitCommand(commander.id, 1, 0),
  context: const GameCommandContext(actorPlayerId: 'player_1'),
);

expect(result.offset, 1);
expect(result.state.units.single.col, 1);
expect(eventLog.commands.single.actorPlayerId, 'player_1');
expect(repository.snapshot.units.single.col, 1);

That test crosses a boundary deliberately:

Application -> CommandTransport -> Reducer -> EventLog -> Snapshot

The test is valuable because it checks the contract between contexts, not only the reducer rule.

Protocol Tests Between Client and Server

The shared core package is the language used by both the Flutter client and the Dart server.

That makes serialization tests boundary tests.

Commands, events, snapshots, match data, AI metadata, and server acknowledgements must survive the wire format. If client and server disagree about JSON, multiplayer breaks even if both sides compile.

A protocol test looks like this:

final ack = WireCommandAck(
  matchId: 'match_1',
  accepted: false,
  offset: 3,
  reason: 'not_allowed',
);

final restored = WireCommandAck.fromJson(ack.toJson());

expect(restored.matchId, 'match_1');
expect(restored.accepted, isFalse);
expect(restored.reason, 'not_allowed');

These tests protect the boundary between processes.

Inside one app, a bad model shape is annoying. Across a network, it becomes a compatibility problem.

Projection Tests as Security Boundaries

Multiplayer adds a different kind of boundary: not every player is allowed to know everything.

The server can store the full match state. A client should receive only the projected state for that player.

That is why SnapshotVisibilityProjector is tested very explicitly.

A projection test checks things like:

  • the player sees their own gold, not every player’s gold,
  • hidden enemy units are removed,
  • visible enemy units lose private queued paths,
  • enemy city production queues are hidden,
  • only the requesting player’s fog and research are included,
  • runtime selections from other players are removed.

Conceptually:

flowchart LR
    Full["Full Server Snapshot"]
    Projector["Visibility Projector"]
    Player["Player-Specific Snapshot"]

    Full --> Projector
    Projector --> Player

This is not only architecture. It is gameplay integrity.

Fog of war should not be a visual trick. The forbidden data should not be sent to the client in the first place.

Presentation Boundary Tests

The presentation layer has its own rules.

Flutter and Flame are allowed here. Riverpod is allowed here. View models are allowed here. UI templates are allowed here.

But presentation should not quietly become infrastructure or domain.

There are architecture tests that guard UI conventions too: no raw modal usage, no duplicate selection widgets, no excessive callback drilling, no raw paint in action palette code, no missing maxLines in places where text can break layout.

At first glance, these may look like style tests. I see them as boundary tests.

They protect the UI system as a context.

A modal should go through the shared modal scaffold. A selection view should use the shared selection patterns. A HUD action should not invent a private rendering style unless it has a good reason.

The boundary is not only “domain versus UI”. There are boundaries inside presentation as well.

Map Context Boundaries

The map context is another area I want to keep clean.

Map data, terrain, coordinates, loaders, validation, and geometry should not depend on game presentation. The map should be usable by the editor, the game, tests, and eventually server-side validation.

So tests check that map domain code does not depend on the game layer or UI frameworks.

That keeps the map context reusable.

A map is not a Flutter widget.
A tile is not a Flame component.
A hex coordinate is not a screen position.

Those translations happen later.

Why These Tests Matter

The most important thing about boundary tests is that they catch architectural decay early.

Without them, a shortcut can feel harmless:

“I’ll just import this provider here.”
“I’ll just call DateTime.now() here.”
“I’ll just use this UI helper in the domain.”
“I’ll just expose this field in the snapshot.”

Each shortcut saves time locally and creates cost globally.

Boundary tests make that cost immediate.

They turn architecture from a diagram into an executable rule.

The Lesson

Testing bounded contexts is not about proving that the code is “clean”. It is about protecting the reasons the architecture exists.

The domain stays testable because it cannot depend on UI.
The server stays portable because it cannot depend on Flutter.
The application stays flexible because infrastructure is behind ports.
The client and server stay compatible because protocol objects round-trip.
Multiplayer stays honest because projections are tested.
The UI stays coherent because presentation conventions are guarded.

For Age of New Worlds, these tests are not decoration. They are part of the design.

A 4X game grows by adding systems, and every new system creates pressure on the boundaries. The test layer is how I make sure those boundaries keep pushing back.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *