Files
agentic-dev/docs/architecture/dependency-flow.md

8.9 KiB

Dependency Flow

                    +-------------+   +-----------------+   +-----------+
                    | apps/web-   |   | apps/web-       |   | apps/cms  |
                    | next        |   | tanstack        |   |           |
                    +------+------+   +--------+--------+   +-----+-----+
                           |                   |                  |
        +------------------+--------------+    |                  |
        |                  |              |    |                  |
   +----v-----+      +-----v------+ +-----v----v---+      +-------v------+
   | core-api |      | core-trpc  | | feature      |      | core-cms     |
   |          |      |            | | packages     |      |              |
   +-----+----+      +-----+------+ +------+-------+      +-------+------+
         |                 |               |                      |
         |                 |               |                      |
         +--+-------+------+---------------+----+   +-------------+
            |       |                           |   |
       +----v---+ +-v---------+         +-------v---v---+
       | core-  | | core-ui   |         | core-shared    |
       | shared | |           |         |                |
       +--------+ +-----------+         +----------------+

  Boundary rules (enforced by ESLint + Turborepo boundaries):
    app             → app, core, core-composition, feature, tooling
    feature         → core, tooling
    core            → core, core-composition, tooling
    core-composition → core, core-composition, feature, tooling
    tooling         → tooling
    
    Composition exceptions:
      core-api  → @repo/<feature>/api (subpath only)
      core-cms  → @repo/<feature>/cms (subpath only)

  App-side feature subpaths (Plan 9):
    @repo/<feature>     — contracts (types, errors, schemas, IUseCase aliases, router type, constants)
    @repo/<feature>/ui  — UI artifacts (query builders, components)

Concrete examples

Allowed:

// in apps/web-next
import { appRouter } from "@repo/core-api";
import { NextTrpcProvider } from "@repo/core-trpc/next";
import { bindProductionBlog } from "@repo/blog/di/bind-production";
import { signInInputSchema, type SignInInput } from "@repo/auth";   // contracts (Plan 9)
import { articleBySlugQuery } from "@repo/blog/ui";                  // queries (Plan 9)

// in packages/blog
import { slugifyIfMissing } from "@repo/core-shared/payload";

// in packages/core-api
import { blogRouter } from "@repo/blog/api";          // composition exception
import { router } from "@repo/core-shared/trpc/init"; // core → core fine

// in packages/core-cms
import { articles } from "@repo/blog/cms";             // composition exception

Disallowed:

// in packages/blog (cross-feature)
import { Article } from "@repo/marketing-pages";     // ❌ feature → feature

// in packages/blog (deep import past public exports)
import { articles } from "@repo/blog/src/integrations/cms/collections/articles"; // ❌ no-private

// in packages/core-shared
import { blogRouter } from "@repo/blog/api";         // ❌ core → feature
import { ArticleNotFoundError } from "@repo/blog";   // ❌ core → feature
                                                     // (defineErrorMiddleware takes Error
                                                     // constructors as args from features —
                                                     // core-shared never imports them)

// in packages/core-trpc
import { someBlogThing } from "@repo/blog";          // ❌ core → feature (only core-api/core-cms have exception)

// in apps (using the wrong subpath)
import { articleBySlugQuery } from "@repo/blog";     // ❌ queries live on ./ui
import { Article } from "@repo/blog/ui";             // ❌ types live on the root subpath

Enforcement strategy

Three layers work in tandem:

  1. package.json dependencies — if you didn't declare it, you can't import it
  2. exports map — blocks deep imports; only public subpaths are accessible
  3. Two parallel automated checks (both enforcing the same five-tag model):
    • ESLint eslint-plugin-boundaries runs at lint time, catching direct-import violations
    • Turborepo boundaries runs at build time, validating the entire workspace graph including transitive dependencies

The two enforcement layers are independent but complementary. ESLint is stricter on per-import context (e.g., file-specific exemptions via // @boundaries-ignore), while Turborepo catches transitive issues that lint-time checking misses. Run pnpm lint and pnpm turbo boundaries in CI to catch all violations.

TRACER / LOGGER (Plan 10)

The instrumentation layer is per-feature container but app-wide instance: each feature container binds INSTRUMENTATION_SYMBOLS.TRACER and INSTRUMENTATION_SYMBOLS.LOGGER to the SAME instance, constructed once by the app's bindAll() dispatcher (Rule 0).

apps/web-next/src/server/bind-production.ts (bindAll)
        │
        ├─ Rule 0: WEB_NEXT_SENTRY_DSN set?
        │     yes → bindSentryInstrumentation(sharedContainer, { dsn, app: "web-next" })
        │     no  → bindNoopInstrumentation(sharedContainer)
        │     ↓
        │   tracer + logger instances
        │     ↓
        ├─ resolveEventsAndJobs* → IEventBus + IJobQueue (ADR-015)
        │     production  → PayloadJobsEventBus + PayloadJobQueue
        │     dev-seed    → InMemoryEventBus  + InMemoryJobQueue
        │     ↓
        ├─ resolveRealtime → IRealtimeBroadcaster + IRealtimeHandlerRegistry (ADR-016)
        │     server.ts   → SocketIORealtimeBroadcaster + RealtimeHandlerRegistry (passed in from server.ts)
        │     page/test   → InMemoryRealtimeBroadcaster + RealtimeHandlerRegistry (defaults)
        │     ↓
        ├─ build ctx: BindProductionContext = { config, tracer, logger, bus, queue, realtime, realtimeRegistry }
        │     Required: tracer, logger, config (production only)
        │     Optional: bus, queue, realtime, realtimeRegistry (guard with ?. when used)
        │     ↓
        ├─ bindProductionBlog(ctx: BindProductionContext)
        │     │
        │     ├─ blogContainer.bind(TRACER).toConstantValue(tracer)
        │     ├─ blogContainer.bind(LOGGER).toConstantValue(logger)
        │     ├─ ArticlesRepository(config, tracer, logger) → bound to IArticlesRepository
        │     │     (real repo: inline this.tracer.startSpan + this.logger.captureException per method)
        │     ├─ withSpan(tracer, ..., withCapture(logger, ..., useCase(deps))) → UseCase symbol
        │     ├─ withSpan(tracer, ..., withCapture(logger, ..., controller(uc))) → Controller symbol
        │     ├─ // <gen:event-handlers>    bus.subscribe(...) (ADR-015, gen event consume)
        │     ├─ // <gen:jobs>              queue.register(...) in dev-seed; Payload task in prod
        │     └─ // <gen:realtime-handlers> realtimeRegistry.register(...) (ADR-016, gen realtime handler)
        │
        └─ (same for auth, marketing-pages, navigation, media — each receives the same ctx)

BindContext shape (from @repo/core-shared/di):

Field Type Required Notes
tracer ITracer always Resolved by Rule 0 (Sentry vs Noop)
logger ILogger always Resolved by Rule 0
config SanitizedConfig production only Present in BindProductionContext, absent in BindContext
bus EventBusProtocol? optional IEventBus at the aggregator; protocol surface at binders
queue IJobQueue? optional Present when core-shared/jobs is wired
realtime RealtimeBroadcasterProtocol? optional IRealtimeBroadcaster at the aggregator
realtimeRegistry RealtimeRegistryProtocol? optional IRealtimeHandlerRegistry at the aggregator

Feature binders destructure ctx and use optional fields with ?. or cast to the full interface when the feature unconditionally requires them (e.g. bus as IEventBus when a use case always needs the event bus).

Why per-feature containers also get the binding: lets internal DI-resolved code in a feature pull TRACER/LOGGER without going through the app dispatcher. In practice, only repository classes and feature-internal services would use this — controllers and use cases receive instrumentation via the bind-time wrapper.

Why the shared container exists at all: isolates Rule 0 resolution from feature containers. Feature containers don't need to know if Sentry is on or off — they just receive an ITracer instance.

Boundary rule: feature packages MUST NOT import @sentry/* directly (R40, ESLint-enforced). The only paths that may import the SDK are core-shared/instrumentation/sentry/**, the bind-sentry-instrumentation files, the no-sentry test guards, and per-app instrumentation*.{ts,mjs} / next.config.{mjs} / vite.config.{ts} entries.