Platform Foundationsv0.2.0

Architecture

Reference architecture for Platform apps, frontend and backend.

Reference architecture for PlainConcepts Platform apps (frontend and backend), plus the layout of this foundation repo. It is intentionally small: it mandates a few rules that matter and leaves the rest to each team. Start simple; add structure only when it earns its place. Frontend is covered first; the backend (.NET) architecture is the final section.

Architecture Overview

  • Foundation repo (PlainConceptsPlatform/Foundations): a pnpm monorepo holding the shared theme package, the shared components package, the docs/showcase site, and the conventions in ai/. It ships tokens + shared components + docs, not a full component library.
  • Platform frontend apps (separate repos): Next.js (App Router) + React + TypeScript, using shadcn/ui components copied in and themed by @plainconceptsplatform/ui-theme. Each app follows a pragmatic Feature-Sliced Design (FSD) structure.
  • Platform backend apps (.NET): Clean Architecture layers with Vertical Slice Architecture and CQS, following the Plain Engineering Excellence conventions. See the Backend architecture section.

Stack

Every app picks the same library for the same job, so codebases (and AI agents) stay predictable. Prefer these; deviate only with a good reason recorded in the app.

Core

ConcernChoiceNotes
LanguageTypeScript (strict)extends tsconfig.base.json
FrameworkNext.js (App Router) + Reactrecommended baseline for all apps
Package managerpnpmused everywhere, incl. this repo
Componentsshadcn/uicopied into the app, on Radix + Tailwind
PrimitivesRadix UIcomes with shadcn
StylingTailwind CSS v4tokens from @plainconceptsplatform/ui-theme
Theme@plainconceptsplatform/ui-themethe Platform styleguide; tokens only
IconsLucide (lucide-react)shadcn default
Dependency injectioninversify + inversify-hooksmandatory; see Dependency injection below
Internationalizationreact-i18next + i18nextmandatory; the only translation library. Zero magic strings; every user-facing text is a translation message

Feature libraries (shadcn delegates to these, use them directly)

NeedChoice
Forms + validationreact-hook-form + zod (@hookform/resolvers)
Data tables / gridsTanStack Table
Data fetching / server cacheTanStack Query (when not using RSC/Server Actions)
Date picker / calendarreact-day-picker
ChartsRecharts
Toasts / notificationssonner
Command palettecmdk
Carousel / Drawerembla-carousel / vaul

Tooling

ConcernChoiceNotes
Lint + formatBiomeone tool; see biome.json
Next-specific lintnext lintBiome lacks Next rules, run both
Unit / component testsVitest + Testing Library
E2E testsPlaywright
Docs + showcaseFumadocs (Next.js)apps/docs
Monorepopnpm workspacesno Turborepo/Nx yet

Deliberately not standardized

Custom shared business components, auth/API/state-management abstractions, observability or feature-flag packages, routing wrappers, app-shell components, a custom CLI, project generators, shared form abstractions, a custom DI framework. Add a shared piece only when the same real requirement has appeared in multiple apps.

Foundation repo structure

Foundations/
├── apps/
│   └── docs/                  # Fumadocs (Next.js), docs + live themed component previews
├── packages/
│   ├── theme/                 # @plainconceptsplatform/ui-theme (design tokens, CSS only)
│   └── ui-components/         # @plainconceptsplatform/ui-components (shared React components)
├── ai/                        # ARCHITECTURE.md (incl. Stack), DESIGN.md, AGENTS.md (source of truth)
├── biome.json · tsconfig.base.json · pnpm-workspace.yaml · package.json
└── .github/workflows/         # CI (Biome + Vitest) and deploy (Fumadocs → Azure App Service)

Foundation repo internals

How this repo produces what it publishes. Worth knowing before changing anything here, because several artifacts are generated and editing the output instead of the input is the usual mistake.

Package graph

flowchart TD
  theme["@plainconceptsplatform/ui-theme<br/>tokens, CSS only"]
  components["@plainconceptsplatform/ui-components<br/>PlainLogo, DataTable"]
  docs["apps/docs<br/>Fumadocs site, private"]
  app["a Platform app<br/>separate repo"]

  components --> theme
  docs --> theme
  docs --> components
  app --> theme
  app --> components

ui-theme has no dependencies of its own beyond a Tailwind peer, which is what keeps it cheap to adopt. apps/docs is private and never published; it consumes both packages through the workspace so the site always documents the code in the same commit.

Content pipeline

Three generators run before the site builds. All of their output is committed so it is reviewable in a diff, and CI fails if regenerating produces a change.

flowchart TD
  aiDocs["ai/ARCHITECTURE.md<br/>ai/DESIGN.md · ai/AGENTS.md"]
  themeCss["packages/theme/src/theme.css"]
  demos["apps/docs/components/previews/*.tsx"]
  data["scripts/components-data.mjs<br/>titles and Platform prose"]

  sync["scripts/sync-docs.mjs"]
  genTokens["scripts/gen-tokens.mjs"]
  genPages["scripts/gen-component-pages.mjs"]

  reference["content/docs/reference/*.md"]
  tokensTs["lib/tokens.generated.ts"]
  pages["content/docs/components/*.mdx"]

  aiDocs --> sync --> reference
  themeCss --> genTokens --> tokensTs
  demos --> genPages
  data --> genPages --> pages

The rule this encodes: ai/*.md and theme.css own facts, and the site renders them. A fact stated in two places drifts, so narrative pages link to the reference rather than restating a table.

Release flow

Edit theme.css, add a changeset, merge. Merging to main opens a "Version Packages" pull request; merging that publishes both packages to npm with provenance. Apps pick up the change when they bump the dependency. Nobody publishes from a laptop.

Deploy topology

apps/docs builds with output: "standalone" and runs as a Node server on Azure App Service. The deploy workflow is gated on CI: it triggers on workflow_run completion and only proceeds when the CI conclusion was a success.

Reference app structure (pragmatic FSD on Next.js)

<app>/
├── src/
│   ├── app/          # Next.js App Router: routing shell only (layouts, segments, route
│   │                 #   handlers) + global providers. FSD "app" concerns live here.
│   ├── views/        # FSD "pages" layer, RENAMED to avoid the Next app/pages collision.
│   │                 #   Composition of a route's screen from widgets/features.
│   ├── widgets/      # (earn it) self-contained UI blocks combining features/entities
│   ├── features/     # (earn it) user interactions / use-cases
│   ├── entities/     # (earn it) business domain models + their UI/api
│   └── shared/
│       ├── ui/       # shadcn components (Radix + Tailwind + Lucide)
│       ├── lib/      # cn(), formatters, pure helpers
│       ├── api/      # API clients / fetchers
│       └── hooks/    # cross-cutting hooks

A brand-new app may start with only app/, views/, and shared/. Introduce widgets/, features/, and entities/ only when a real need appears, never preemptively.

Dependency direction

The one hard rule. A layer may import from layers below it, never above; siblings do not import each other (compose them one layer up).

flowchart TD
  app["app (routing shell + providers)"] --> views
  views["views (screens)"] --> widgets
  widgets --> features
  features --> entities
  entities --> shared["shared (ui, lib, api, hooks)"]

Rationale: one-way dependencies keep slices independent and safe to delete/move, and make the codebase legible to humans and agents, you always know where a thing can live.

Public APIs for slices

Every slice exposes a single index.ts (barrel). Import a slice only through its index.ts, never deep-reach into its internals. This makes each slice's surface explicit and refactor-safe.

Where things belong

  • App initialization / providers: src/app (root layout + a providers module).
  • Routing: Next.js App Router in src/app; the screen it renders lives in views/.
  • Reusable domain logic: entities/ (models, domain rules) and features/ (use-cases).
  • UI components: third-party components in shared/ui (shadcn); app-specific components in the slice that owns them. Do not wrap third-party components "just in case".
  • API clients: shared/api (or per-entity api/), behind small typed interfaces.
  • Hooks: cross-cutting in shared/hooks; feature-specific inside the feature.

Dependency injection (inversify-hooks, mandatory)

Frontend apps use InversifyJS with inversify-hooks as the dependency-injection layer. Depend on interfaces, register implementations in the container, and resolve them in components through the hooks. This keeps use-cases and API clients swappable and testable, and gives every app the same wiring pattern.

  • Libraries: inversify + inversify-hooks.
  • Register bindings in a composition root under app/. Resolve with the provided hook inside views, widgets, and features. Keep entities/domain code free of the container.
  • The underlying principle still holds: depend on abstractions, not concretions. The container is how we implement that here.

Because inversify uses decorators, apps need these tsconfig compiler options. The shared tsconfig.base.json already sets experimentalDecorators and useDefineForClassFields: false:

{
  "compilerOptions": {
    "target": "es2020",
    "lib": ["es2020", "dom"],
    "moduleResolution": "bundler",
    "experimentalDecorators": true,
    "useDefineForClassFields": false
  }
}

Next.js apps must also enable decorator support in the compiler (SWC/Babel) so decorators work at runtime, not only in type-checking.

Testing conventions

  • Vitest + Testing Library for unit/component tests, co-located as *.test.ts(x) next to the code. Test behavior via the public API of a slice, not internals.
  • Playwright for end-to-end flows, in a top-level e2e/.
  • Every feature/entity with logic ships tests; UI-only wrappers need not.

Naming conventions

  • Files/folders: kebab-case. React components: PascalCase export. Hooks: useX.
  • Slice folders are singular domain nouns (user, invoice), not utils-style grab-bags.
  • One barrel index.ts per slice; no deep imports across slices.

When a simpler structure is acceptable

Small/short-lived apps may collapse to app/ + views/ + shared/. The dependency direction and slice public-API rules still apply; the extra layers are optional. Prefer the smallest structure that stays legible.

Docs & showcase

apps/docs is a Fumadocs (Next.js) site rendering these docs plus live, themed component previews and 2-3 realistic screens. It demonstrates the theme and the shared components (from @plainconceptsplatform/ui-components); it links out to shadcn's official docs rather than re-documenting component APIs. Deployed to Azure App Service, with Entra ID login via App Service Authentication.

Backend architecture (.NET)

Reference for Platform .NET/C# backends. The source of truth is the Plain Engineering Excellence skills; this section summarizes them and shows how a Platform .NET app applies them.

Two skills, published to PlainConcepts/skills as the pc-companion plugin:

SkillScopeCovers
plain-engineering-conventionsLanguage-agnosticArchitecture principles, error handling, observability, DI, testing, security, CI/CD, linting
plain-dotnet-guardrails.NET / C#VSA + CQS, EF Core, Aspire, DI (Scrutor), logging, build hygiene, Central Package Management, .editorconfig, architecture tests

Both are reference-only (they do not scaffold). The pc-companion agent analyzes a project against these conventions and produces a findings-report.md. It coaches, it does not police.

Core engineering principles

From plain-engineering-conventions:

#PrincipleOne-liner
1Repository StructureStandard layout, mandatory docs, root config files
2Result-Oriented ErrorsDomain errors as values, not exceptions, plus RFC 7807
3Explicit BoundariesPublic interfaces between components; no coupling to internals
4Observability by DefaultStructured logging, distributed tracing, metrics
5Convention over ConfigurationAuto-discovery of handlers, validators, modules
6Architecture as CodeEnforce rules with tests and linters, not just docs
7Everything as CodeIaC, CI/CD, GitOps; nothing manual
8Code Quality and LintingAutomated formatting and static analysis, enforced in CI
9Security by DefaultVulnerability scanning, dependency auditing, secret detection in CI
10Testing StrategyA deliberate pyramid: unit, integration/functional, E2E

.NET stack and guardrails

From plain-dotnet-guardrails:

  • Architecture: Vertical Slice Architecture (feature per folder) with Command/Query Separation, organized as a modular monolith (a module is a bounded context). Add modules only when a real boundary exists.
  • Errors: the ErrorOr result pattern mapped to RFC 7807 ProblemDetails. Domain errors are return values, not thrown exceptions.
  • Dependency injection: convention-based registration with Scrutor assembly scanning.
  • Persistence: EF Core.
  • Orchestration: Aspire conventions for resources and connection strings.
  • Build hygiene: Directory.Build.props / .targets, Central Package Management (Directory.Packages.props), and a .editorconfig whose rules are enforced in CI.
  • Architecture tests: a dedicated test project asserts layer and dependency rules.
  • Logging: ILogger<T> with structured logs and sensible levels.

Reference architecture (Clean Architecture layers)

A Platform .NET API is organized into layered projects:

ProjectResponsibility
DomainDomain model (entities, value objects, domain events). No outward dependencies.
SharedKernelShared DDD building blocks and base types.
ApplicationUse-cases and DTOs, organized by feature (vertical slices). Depends on Domain.
InfrastructureEF Core, external integrations. Implements Application/Domain interfaces.
ApiMinimal-API endpoints grouped by feature (Endpoints/<Feature>/...). Composition root.
Gateway, SeedSupporting services (edge/gateway, data seeding).
UnitTests, IntegrationTests, E2ETestsThe test pyramid.

Dependency direction (inner layers know nothing about outer layers):

flowchart TD
  api["Api (endpoints, composition root)"] --> app["Application (features, CQS)"]
  infra["Infrastructure (EF Core, integrations)"] --> app
  app --> domain["Domain (entities, VOs, events)"]
  infra --> domain
  domain --> kernel["SharedKernel (DDD building blocks)"]

Inside each layer, work is grouped by feature: an endpoint maps to an application command/query, which uses the domain and persists through infrastructure.

The libraries above (ErrorOr, Scrutor, Aspire, EF Core) are the Plain conventions and recommended defaults; confirm each in a given app rather than assuming it.

When to use what

  • A service or small app: a few layered projects (Domain, Application, Infrastructure, Api) with feature folders inside. This is the default.
  • A larger system: a modular monolith where each module is a bounded context and is internally VSA + CQS. Split into separate deployables only when a boundary genuinely demands it.

Do not add modules, layers, or a mediator preemptively.

Backend testing

Follow the pyramid: many unit tests (xUnit), fewer integration/functional tests (a real host via WebApplicationFactory, a real database via Testcontainers), and a few E2E tests. Keep unit, integration, and end-to-end tests in separate projects.

Everything as code, security, observability

Infrastructure, pipelines, and configuration live in the repo (IaC, CI/CD, GitOps). Security checks (dependency and secret scanning, SAST) run in CI. Observability uses structured logging and OpenTelemetry.