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 inai/. 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
| Concern | Choice | Notes |
|---|---|---|
| Language | TypeScript (strict) | extends tsconfig.base.json |
| Framework | Next.js (App Router) + React | recommended baseline for all apps |
| Package manager | pnpm | used everywhere, incl. this repo |
| Components | shadcn/ui | copied into the app, on Radix + Tailwind |
| Primitives | Radix UI | comes with shadcn |
| Styling | Tailwind CSS v4 | tokens from @plainconceptsplatform/ui-theme |
| Theme | @plainconceptsplatform/ui-theme | the Platform styleguide; tokens only |
| Icons | Lucide (lucide-react) | shadcn default |
| Dependency injection | inversify + inversify-hooks | mandatory; see Dependency injection below |
| Internationalization | react-i18next + i18next | mandatory; the only translation library. Zero magic strings; every user-facing text is a translation message |
Feature libraries (shadcn delegates to these, use them directly)
| Need | Choice |
|---|---|
| Forms + validation | react-hook-form + zod (@hookform/resolvers) |
| Data tables / grids | TanStack Table |
| Data fetching / server cache | TanStack Query (when not using RSC/Server Actions) |
| Date picker / calendar | react-day-picker |
| Charts | Recharts |
| Toasts / notifications | sonner |
| Command palette | cmdk |
| Carousel / Drawer | embla-carousel / vaul |
Tooling
| Concern | Choice | Notes |
|---|---|---|
| Lint + format | Biome | one tool; scoped biome.json per package, never run at monorepo root |
| Next-specific lint | next lint | Biome lacks Next rules, run both |
| Markdown lint | markdownlint-cli2 | one rule, no-hard-wrapped-prose; .markdownlint-cli2.jsonc at the repo root, not scoped per package |
| Unit / component tests | Vitest + Testing Library | |
| E2E tests | Playwright | |
| Docs + showcase | Fumadocs (Next.js) | apps/docs |
| Monorepo | pnpm workspaces | no 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)
│ └── workflows/ # README linking to the agentic-workflows repo
├── biome.json · .markdownlint-cli2.jsonc · tsconfig.base.json · pnpm-workspace.yaml · package.json
└── .github/workflows/ # CI (Biome, Markdown), deploy (Fumadocs → Azure), agentic workflowsFoundation 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 hooksA 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 + aprovidersmodule). - Routing: Next.js App Router in
src/app; the screen it renders lives inviews/. - Reusable domain logic:
entities/(models, domain rules) andfeatures/(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-entityapi/), 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 insideviews,widgets, andfeatures. Keepentities/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
- UI-only wrappers need not ship tests. Logic belongs in apps, not in this foundation repo.
Naming conventions
- Files/folders:
kebab-case. React components:PascalCaseexport. Hooks:useX. - Slice folders are singular domain nouns (
user,invoice), notutils-style grab-bags. - One barrel
index.tsper 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.
Recommended skills (source of truth)
Two skills, published to PlainConcepts/skills as the pc-companion plugin:
| Skill | Scope | Covers |
|---|---|---|
plain-engineering-conventions | Language-agnostic | Architecture 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:
| # | Principle | One-liner |
|---|---|---|
| 1 | Repository Structure | Standard layout, mandatory docs, root config files |
| 2 | Result-Oriented Errors | Domain errors as values, not exceptions, plus RFC 7807 |
| 3 | Explicit Boundaries | Public interfaces between components; no coupling to internals |
| 4 | Observability by Default | Structured logging, distributed tracing, metrics |
| 5 | Convention over Configuration | Auto-discovery of handlers, validators, modules |
| 6 | Architecture as Code | Enforce rules with tests and linters, not just docs |
| 7 | Everything as Code | IaC, CI/CD, GitOps; nothing manual |
| 8 | Code Quality and Linting | Automated formatting and static analysis, enforced in CI |
| 9 | Security by Default | Vulnerability scanning, dependency auditing, secret detection in CI |
| 10 | Testing Strategy | A deliberate trophy: static analysis, a thin layer of unit tests for real logic, mostly integration/functional, a few 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
ErrorOrresult 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. The AppHost is the only composition root: one C# file declares the database containers (persistent lifetime + data volume), the API with injected connection strings and
WaitForhealth gating, and the web frontend with a fixed dev port. Every service references the ServiceDefaults project for OpenTelemetry, service discovery, resilience, and health endpoints, and consumes databases through the Aspire client integrations (Aspire.*packages). Repos do not carry a docker-compose file alongside it. - Build hygiene:
Directory.Build.props/.targets, Central Package Management (Directory.Packages.props), and a.editorconfigwhose 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:
| Project | Responsibility |
|---|---|
Domain | Domain model (entities, value objects, domain events). No outward dependencies. |
SharedKernel | Shared DDD building blocks and base types. |
Application | Use-cases and DTOs, organized by feature (vertical slices). Depends on Domain. |
Infrastructure | EF Core, external integrations. Implements Application/Domain interfaces. |
Api | Minimal-API endpoints grouped by feature (Endpoints/<Feature>/...). Composition root. |
Gateway, Seed | Supporting services (edge/gateway, data seeding). |
UnitTests, IntegrationTests, E2ETests | The test layers; most of the weight sits in IntegrationTests. |
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 testing trophy (Kent C. Dodds; Spotify's honeycomb for services with many collaborators), not the pyramid: most tests are integration/functional (a real host via WebApplicationFactory, a real database via Testcontainers, assertions through the HTTP surface), unit tests (xUnit) cover logic with real branching, and a few E2E tests cover the critical journeys. A bug fix gets one regression test, and tests of constants, configured defaults or mocks of your own code are not written. Keep unit, integration, and end-to-end tests in separate projects. Coverage floors, mutation testing and the complexity gate that hold these tests to a bar are in ENGINEERING.md.
Everything as code, security, observability
Infrastructure, pipelines, and configuration live in the repo (IaC, CI/CD, GitOps). Security checks (dependency and secret scanning, SAST, SBOM, and DAST against a test environment) run in CI as required checks; the full set of gates, their modes, and how Project-Health scores them are in ENGINEERING.md. Observability uses structured logging and OpenTelemetry.
Keeping Platform apps out of search engines
Every Platform app is internal and lives on a *.plainconcepts.com subdomain, so nothing it serves may compete with or leak into the company's public site in search results. Three rules, all mandatory:
X-Robots-Tag: noindex, nofollowon every response (HTML, JSON, static files, errors and redirects), set in code by a middleware registered first so it survives the exception handler. The edge (Application Gateway / Front Door) may add it as a second layer, never instead of the code./robots.txtwithUser-agent: */Disallow: /, behind a setting (Robots:DisallowAll) that is switched on only after thenoindexheader has been live long enough for crawlers to see it. Blocking crawling first means a crawler never fetches the page, never reads thenoindex, and an already-indexed URL can stay indexed. A brand-new hostname with no history can switch it on from day one.- The root never shows the app's own content to an anonymous visitor: without a session it goes straight to the Entra ID sign-in (server-side redirect when the routing allows it; otherwise a sign-in screen with no branding or copy while it redirects). Public routes an app deliberately exposes (share links, escalation tokens) are the only exceptions and must also carry the header.