The package boundary needs a test

P6X4 replaced several network walls with TypeScript modules, then enforced public APIs, dependency direction, table ownership, and negative authorization.

The first forbidden import in P6X4 looked harmless:

import { documentTable } from "@harbor/documents/internal/schema"

The search package needed one field. Both packages lived in the same repository. TypeScript could resolve the path, the test passed, and no network contract objected.

The import also bypassed the document module's authority, coupled search to persistence shape, and made future schema work affect a projection directly.

I had removed a service boundary and discovered that folder names are not enforcement.

A boundary is a claim a test can challenge

P6X4's package map claimed:

  • Callers use public entry points.
  • Dependencies flow in declared directions.
  • Each module governs its persistence.
  • Cross-module decisions use typed commands, queries, or facts.
  • Runtime roles receive only required capabilities.
  • Authorization cannot be bypassed by an internal import.

Without executable checks, those were style preferences. Convenience would erode them one import at a time.

I created a boundary test suite alongside ordinary unit and scenario tests. It inspected source, package metadata, database access declarations, and runtime composition. The suite was not meant to prove the architecture correct. It made specific violations cheap to detect.

Public exports were explicit

Every domain package exposed one or more deliberate entry points through package exports:

{
  "name": "@harbor/documents",
  "exports": {
    ".": "./src/index.ts",
    "./contracts": "./src/contracts.ts",
    "./testing": "./src/testing/index.ts"
  }
}

Internal paths were not wildcard-exported. Callers could use the domain API, stable contracts, and sanctioned test builders. They could not reach repositories, database schemas, reducer internals, or composition code.

TypeScript path aliases mirrored the exports instead of mapping @harbor/* to every src folder. The build resolved packages as consumers would. A test compiled small negative fixtures and expected private subpath imports to fail.

This caught a limitation of relying only on lint rules: an editor or alternate build could still resolve a physical relative path. I also scanned imports for traversal into another package root and rejected them in continuous integration.

The public surface remained intentionally small enough to review in a diff.

The dependency graph had named directions

I declared allowed package relationships in a compact policy:

documents:
  may_depend_on: [identity-contracts, audit-contracts, platform]
rendering:
  may_depend_on: [document-contracts, platform]
delivery:
  may_depend_on: [render-contracts, identity-contracts, platform]
search:
  may_depend_on: [document-facts, platform]
platform:
  may_depend_on: []

The test parsed static imports and built a graph. It failed on undeclared edges and cycles. Type-only imports counted because they still couple source evolution.

I did not create a universal “shared” package. Shared code had to describe a stable cross-domain concept or a domain-free platform capability. A utility used by two packages remained in the more appropriate package until its independent meaning was clear.

This avoided a common loophole: moving forbidden knowledge into common and letting every module depend on it.

The document package initially exported its DocumentRow interface because it was already typed. Search and rendering selected the fields they needed. A migration from nullable title to revision-owned title then changed consumers that should not have known the table shape.

I separated types by purpose:

  • Commands described requested decisions.
  • Query results described supported read contracts.
  • Domain facts described committed external meaning.
  • Persistence types remained internal.
  • View models belonged to the interface composing them.

A small API-extractor script inspected exported declarations and rejected references to internal module paths, database-client types, HTTP request objects, and framework-specific errors.

Structural typing created another trap. Two types with the same fields could be assigned accidentally even when their meanings differed. Stable identities used branded types such as DocumentId, RevisionId, and EffectId. Conversion occurred at validated boundaries.

The goal was not type cleverness. It was to make a current document, an artifact snapshot, and a search hit difficult to substitute for one another merely because each contained id and title.

Table ownership needed runtime enforcement

Source rules could stop imports and still allow one package to run raw SQL against another module's tables.

Each module received a restricted repository port constructed with a database role or schema-scoped client. In test and deployment setup, the role's grants matched a checked-in ownership manifest.

The search indexer could read published document facts or an approved export view. It could not select internal document tables. The rendering worker could update render jobs and receipts but not document revisions. Cross-module reporting queries lived in a named reporting package with read-only views and explicit review.

Boundary tests compared database migrations with ownership metadata. A new table without an owner failed. A grant not declared by a runtime role failed. Scenario tests attempted negative operations and expected database refusal, not only a TypeScript error.

This mattered because one process can accumulate a highly privileged connection easily. A modular monolith needs stronger composition discipline precisely because the network no longer supplies accidental separation.

Positive tests proved that an allowed document owner could request a render. They did not prove that internal callers could not bypass policy.

I wrote a suite around denied paths:

  • Search could not return a restricted document based solely on a stale projection.
  • Rendering could not construct a command from a raw document ID without an accepted authority context.
  • Delivery could not change destination after an effect intent was accepted.
  • A web handler could not call internal repository methods to skip revision checks.
  • Background roles with valid application code but wrong database identity were refused.

Public commands required a DecisionContext produced by the identity and policy boundary. It was not a boolean authorized: true; it contained subject, action, resource revision, policy revision, and decision ID. Packages accepted only the context appropriate to their command and recorded it with consequential intents.

I avoided making the context forge-proof through TypeScript alone. Server composition constructed it from trusted policy evaluation, while runtime identities and scenario tests enforced the boundary.

Negative tests made “internal” stop meaning “automatically trusted.”

Architecture tests needed escape hatches

Absolute rules can force worse designs. Migrations, data exports, and one-off recovery sometimes need access beyond ordinary package APIs.

I allowed exceptions through checked-in boundary decisions containing:

  • Exact source and target.
  • Reason the public API was insufficient.
  • Allowed operation and data scope.
  • Expiration date or removal condition.
  • Required scenario or audit evidence.

The test read these records and failed when an exception expired. Broad patterns such as packages/* were rejected. An exception could not silently become precedent; new use required a new decision.

The migration tool that compared old and new document tables had a time-bounded read grant. Once the write fence completed, the grant and exception disappeared together.

This made the rules strict without pretending architecture never needs a controlled breach.

Test builders belonged to the boundary

Consumers had imported internal constructors because creating a valid document fixture through the public command API was verbose.

I added a testing export with builders for public contracts and scenario fixtures. Builders defaulted to valid minimal values but required explicit authority and revision when those affected behavior. They returned public types, not internal domain objects.

For package unit tests, internal helpers remained private to that package. Cross-package tests behaved like consumers.

This reduced pressure to expose internals “only for tests.” It also made contract evolution visible: changing a required public field updated one supported builder and consumer scenarios rather than dozens of hand-written row shapes.

The testing surface was versioned and reviewed as an API. Test convenience can otherwise become the widest back door through a module.

Events were checked for semantic ownership

A central events package had once defined every message. The dependency graph looked clean because all domains depended on events; conceptually, the central package owned the entire product vocabulary.

Facts moved beside the authority that published them. The document package exported DocumentRevisionCommitted; rendering exported DocumentRendered; delivery exported effect outcomes. Consumers depended on those contract subpaths, not on publisher internals.

Commands belonged to the capability asked to decide or act. A renderer defined RenderDocument, including accepted versions and possible terminal outcomes.

The architecture test enforced that only the owning package could construct a published fact through its production API. Test fixtures could create examples through the contract builder. This prevented another module from announcing DocumentRendered merely because it could satisfy the TypeScript shape.

Type compatibility did not confer authority to make a claim.

Static import graphs missed workers resolved from string names and generated clients produced during the build.

The workflow registry used explicit checked-in registration instead of arbitrary module paths from data. Its generated inventory listed handler package, workflow type and version, runtime role, and required ports. The boundary suite validated every handler against allowed dependencies.

Generated code was emitted into the owning package and could depend only on the source contract and platform serialization. It could not become a secret shared layer. The generator included provenance so a diff showed which contract change produced it.

I also searched compiled output for forbidden package paths as a backstop. It was not a substitute for source analysis, but it caught one build alias that bypassed the intended export resolution.

Architecture enforcement had to observe the ways the actual build assembled code, not only the cleanest source syntax.

Runtime composition was tested as data

Each entry point declared required packages, database grants, outbound services, queues, and secrets. The boundary suite compared these manifests with source imports and dependency constructors.

If render-worker imported a delivery adapter, the source graph and role manifest disagreed. If deployment injected a credential not declared by the role, validation failed. If a package requested a database port unavailable to its role, composition failed before startup.

A generated runtime page showed:

RoleDomain capabilitiesData grantsOutbound capabilities
WebIdentity, documentsCurrent authority, accepted intentsNone directly
RendererRendering, document contractsRender jobs and receiptsAsset fixture
DeliveryDelivery, render contractsEffect ledgerAdapter fixtures
IndexerSearch, document factsProjection checkpointSearch index

The page was useful because it made package boundaries and deployment permissions one conversation.

Boundary tests were especially valuable during consolidation. When the old notification service disappeared, the suite denied imports of its client, references to its topic, and grants for its credentials. A retired identifier list caught accidental resurrection.

For data movement, tests prohibited new writes to compatibility schemas after the authority epoch changed. Old readers could remain under a bounded exception, but no package could acquire the old repository port.

This encoded architectural subtraction as an invariant. A future convenience patch could not quietly restore the distributed path without changing a visible decision.

I kept the tests comprehensible

An architecture tool can become a private programming language no one wants to debug. P6X4's rules were small enough to print in a failure:

rendering/src/job.ts imports documents/internal/repository.ts
 
rendering may depend on @harbor/documents/contracts.
It may not depend on document internals.
 
Use DocumentRevisionRef or record a bounded exception.

Every failure linked to the policy and suggested the supported surface. Graph output was an aid, not the only explanation.

I kept ordinary linter configuration for syntax-level restrictions and a dedicated script for cross-source checks. Scenario tests covered runtime and authorization consequences. No single tool pretended to enforce every dimension.

The suite ran quickly enough for local feedback. Slower database-grant and assembled-build checks ran in the full verification path.

Boundaries changed through decisions

The policy was versioned beside the code. Adding a legitimate dependency required updating the allowed graph and a short boundary decision explaining what responsibility moved or became shared.

Some changes were correct. Delivery eventually depended on a narrow identity-contract package for destination authorization. Search stopped depending on broad document query results and consumed published facts instead. Audit moved into the document transaction and ceased to be a separate domain service.

The test did not freeze the first diagram. It prevented the diagram from changing invisibly.

A boundary whose every exception was continually renewed was evidence that the rule or module division no longer matched reality. I reviewed that pattern rather than celebrating a perfect graph achieved through paperwork.

Before consolidation, HTTP status codes, JSON schemas, and separate deployments made interfaces look deliberate. Several were broad, coupled, and trusted by convention. Moving them into one repository stripped away transport ceremony and exposed their meaning.

The package boundary suite forced better questions:

  • What may a caller actually ask this capability to do?
  • Which facts may it conclude from the response?
  • Who can publish this fact?
  • Which table holds authority?
  • What data can this runtime identity reach?
  • What failure is contained here?

The answers became commands, contracts, grants, and negative scenarios.

One forbidden import did not threaten P6X4 by itself. Hundreds of harmless shortcuts would eventually turn the modular monolith into shared mutable code.

The test made every shortcut visible while it was still small.

A package boundary is not real because the folder has a good name. It is real when a violating dependency fails before it becomes the new normal.

Performance shortcuts still crossed the public surface

One projection route was slow enough that joining document and policy tables directly looked justified. The query was faster than composing public calls and avoided repeated allocation.

Instead of granting search permanent access, I first measured where the time went. Most cost came from loading broad domain objects that the supported query did not need. The document package gained a narrow, read-only export view with stable revision and authorization fields. The search indexer consumed it in batches through an approved port.

The boundary decision documented that the view was a projection source, not a new authority. Schema changes had contract fixtures, and a negative test denied mutation. Performance improved without letting a hot path redefine ownership silently.

The lesson was not that boundaries outrank latency. It was that an optimization should produce the narrow contract it actually needs.

Repository-wide renames and automated code transforms could bypass review by changing internals and consumers together. I configured transformations to operate package by package and report public-surface changes separately.

When a public contract changed, the migration had an expansion phase: add the new field or function, move consumers, then remove the old surface. Directly editing every package in one atomic change was allowed only for contracts with no durable or independently released consumers, and the decision remained visible.

The boundary suite ran on intermediate commits during larger refactors. This caught cycles that the final result might remove but that made the work difficult to review and bisect.

Tooling speed did not make architectural coordination free. It made it easier to hide.

Although domain packages shared one application release, they did not all need to import the same framework libraries. A dependency inventory showed which packages relied on React, the web framework, database client, broker client, or rendering binaries.

Domain logic packages were kept free of transport and UI frameworks. Adapters translated at composition boundaries. During an upgrade, the affected graph therefore reflected actual integration points instead of every package inheriting the dependency through a shared barrel.

I added a rule against exporting third-party framework types from domain contracts. Otherwise, an apparently internal upgrade could become a repository-wide API migration.

This was another form of boundary test: the package manager graph should not contradict the conceptual graph.

A small architecture page made drift discussable

The suite generated a static page with packages, allowed edges, public exports, table ownership, runtime roles, and current exceptions. Clicking an edge showed imports and the decision that permitted them.

I kept the visualization subordinate to text. Its value was spotting drift: a platform package growing domain dependencies, an exception nearing expiry, or one module acquiring too many inbound contracts.

The page did not score modularity or celebrate zero dependencies. A useful system has relationships. It made those relationships inspectable before they hardened into folklore.

The generated view also improved future writing. When an article referred to “the rendering boundary,” I could verify whether that meant package, process, data, or effect at that point in P6X4's history.