Files
agentic-dev-template/docs/architecture/dependency-flow.md
Danijel Martinek b455ae8018 docs(architecture): correct package lists and feature-to-feature boundary
Align the architecture docs with the current repo:

- Boundary matrix: feature may depend on core, feature, tooling — a
  feature may import another feature's public exports. overview.md,
  dependency-flow.md, and vertical-feature-spec.md all said the stale
  `feature -> core, tooling`.
- Optional-core lists completed with core-analytics, core-consent,
  core-dsr; tooling list completed with core-testing.
- Package count corrected to the accurate 19-package breakdown.
- BindContext table gained analytics, consentFactory, rateLimit.

Deeper drift (the HTML explainers, vertical-feature-spec §5/§9.5/§11)
is tracked in the local .tmp/ working note, not yet addressed.
2026-05-22 09:53:36 +02:00

13 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, feature, tooling
    core            → core, core-composition, tooling
    core-composition → core, core-composition, feature, tooling
    tooling         → tooling

    feature → feature: a feature may import another feature's PUBLIC exports
      (its @repo/<feature> contract barrel — types, errors, schemas, event
      contracts). It must NOT reach another feature's internals, and
      cross-feature behaviour still flows through IEventBus — never a direct
      use-case call.

    Composition exceptions:
      core-api  → @repo/<feature>/api (subpath only)
      core-cms  → @repo/<feature>/cms (subpath only)

  App-side feature subpaths:
    @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
import { articleBySlugQuery } from "@repo/blog/ui"; // queries

// in packages/blog
import { slugifyIfMissing } from "@repo/core-shared/payload";
import { userSignedUpEvent } from "@repo/auth"; // ✓ another feature's public contract

// 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 (reaching another feature's internals)
import { signInUseCase } from "@repo/auth/src/application/use-cases/sign-in.use-case"; // ❌ not a public export

// 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-shared (or any non-composition core package)
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 / METRICS / AUDIT (ADR-014, ADR-017, ADR-018)

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

Substrate: OpenTelemetry SDK. Sentry is the exporter via @sentry/opentelemetry. PII scrubbing runs at the OTel processor layer (PiiScrubSpanProcessor + PiiScrubLogRecordProcessor) before the Sentry exporter sees the data. Feature code is never coupled to the OTel SDK or Sentry SDK directly (ADR-017).

Audit is a parallel channel with different durability and redaction contracts (ADR-018). Whereas OTel is sampled and pruned (best-effort observability), audit is append-only and retained for compliance. auditLog is bound by resolveAudit and added to ctx for feature binders that opt in; the binding is wrapped in TraceIdEnrichingAuditLog so every AuditEntry auto-correlates with the active OTel span via correlationId. See docs/guides/audit-and-compliance.md and the visual explainer at docs/architecture/audit-and-compliance-explainer.html.

apps/web-next/src/server/bind-production.ts (bindAll)
        │
        ├─ Rule 0: WEB_NEXT_SENTRY_DSN set?
        │     yes → bindOtelInstrumentation(sharedContainer, { dsn, app: "web-next" })
        │             → initOtelServerNode(dsn, ...) → OTel SDK + Sentry exporter + PII scrub processors
        │     no  → bindNoopInstrumentation(sharedContainer)
        │     ↓
        │   tracer (OtelTracer) + logger (OtelLogger) + metrics (OtelMetrics) 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)
        │     ↓
        ├─ resolveAudit → IAuditLog (ADR-018)
        │     production  → TraceIdEnrichingAuditLog( MultiSinkAuditLog([StdoutJsonAuditLog, PayloadAuditLog]) )
        │     dev-seed    → TraceIdEnrichingAuditLog( StdoutJsonAuditLog ) — or Noop when core-audit is absent
        │     ↓
        ├─ build ctx: BindProductionContext = { config, tracer, logger, metrics?, bus, queue, realtime, realtimeRegistry, auditLog }
        │     Required: tracer, logger, config (production only)
        │     Optional: metrics, bus, queue, realtime, realtimeRegistry, auditLog (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 (OTel+Sentry vs Noop)
logger ILogger always Resolved by Rule 0
metrics MetricsProtocol? optional Resolved by Rule 0; per-feature adoption is opportunistic
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
auditLog AuditLogProtocol? optional IAuditLog at the aggregator; pre-wrapped in TraceIdEnrichingAuditLog so callers don't supply correlationId (ADR-018)
analytics AnalyticsProtocol? optional IAnalytics product-analytics channel at the aggregator (ADR-024)
consentFactory ConsentFactoryProtocol? optional builds a per-subject consent checker; present when core-consent is wired (ADR-025)
rateLimit IRateLimit? optional per-use-case rate-limit budgets; present when rate limiting is wired (ADR-025)

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 the exporter or not — they just receive an ITracer instance.

Boundary rule: feature packages MUST NOT import @sentry/* or @opentelemetry/sdk-* directly (R40 + R52, ESLint-enforced). The OTel bridge (otel/sentry-bridge.ts), browser init files (sentry/init-client*.ts), and app-level instrumentation*.{ts,mjs} / next.config.{mjs} / vite.config.{ts} entries are the only allowlisted paths. Likewise, features MUST NOT import @repo/core-audit directly — they consume IAuditLog via the AuditLogProtocol type re-exported from @repo/core-shared/di/bind-protocols.