Three follow-up corrections from the docs audit: - vertical-feature-spec § 13 dropped the events/jobs bullet from the "Out of scope (deferred)" list — they shipped, the bullet didn't belong there. The spec already mentions ADR-015 elsewhere (§ 4 optional folders). - vertical-feature-spec § 14 success criterion replaced "Any cross-feature import fails pnpm lint" with the post-ADR-015 reality: cross-feature imports are restricted to event contracts (the feature boundary tag now accepts feature-tagged deps), but rule E1 (no-handler-reexport) keeps consumer handlers / use cases / repositories private. - dependency-flow now shows the resolveEventsAndJobs* step in bindAll and the (config, tracer, logger, bus, queue) binder signature, plus the // <gen:event-handlers> / // <gen:jobs> anchor lines where the generators inject subscribe / register calls.
133 lines
7.2 KiB
Markdown
133 lines
7.2 KiB
Markdown
# 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:
|
|
```ts
|
|
// 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:
|
|
```ts
|
|
// 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
|
|
│ ↓
|
|
├─ bindProductionBlog(config, tracer, logger, bus, queue)
|
|
│ │
|
|
│ ├─ 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
|
|
│
|
|
└─ (same for auth, marketing-pages, navigation, media)
|
|
```
|
|
|
|
**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.
|