This is the abridged developer documentation for Granit # Architecture > Design decisions, patterns, and ADRs This section documents the architectural decisions and design patterns used throughout the Granit framework. ## Sections [Section titled “Sections”](#sections) * **[Pattern Library](./patterns/)** — 51 design patterns with their concrete implementation in Granit, organized by category (architecture, cloud/SaaS, GoF, data, concurrency, .NET idioms, security) * **[ADRs](./adr/)** — 16 Architecture Decision Records documenting key technology choices (Serilog, Redis, Wolverine, Scriban, ClosedXML, etc.) ## Design principles [Section titled “Design principles”](#design-principles) Granit is built on a set of explicit architectural principles: * **Convention over configuration** — sensible defaults, explicit overrides * **Module isolation** — each module owns its DbContext, its DI registrations, and its public API surface * **CQRS everywhere** — `IReader` and `IWriter` interfaces are never merged * **Soft dependencies** — modules access cross-cutting concerns (tenancy, time, user context) via `Granit.Core` interfaces, not direct package references * **Compliance by design** — GDPR and ISO 27001 constraints are architectural decisions, not afterthoughts # Architecture Decision Records > Key technical decisions and their rationale Architecture Decision Records (ADRs) document significant technical decisions made during the development of Granit. Each ADR follows a consistent template: Context, Decision, Evaluated Alternatives, Justification, and Consequences. ## ADR index [Section titled “ADR index”](#adr-index) | # | Title | Status | Date | Scope | | ---------------------------------------- | --------------------------------------------------- | -------- | ---------- | ------------------------------- | | [001](001-observability/) | Observability Stack — Serilog + OpenTelemetry | Accepted | 2026-02-21 | Granit.Observability | | [002](002-redis/) | Redis via StackExchange.Redis — Distributed Cache | Accepted | 2026-02-21 | Granit.Caching | | [003](003-testing-stack/) | Testing Stack — xUnit v3, NSubstitute and Bogus | Accepted | 2026-02-21 | granit-dotnet | | [004](004-asp-versioning/) | Asp.Versioning — REST API Versioning | Accepted | 2026-02-22 | Granit.ApiVersioning | | [005](005-wolverine-cronos/) | Wolverine + Cronos — Messaging, CQRS and Scheduling | Accepted | 2026-02-22 | Granit.Wolverine | | [006](006-fluentvalidation/) | FluentValidation — Business Validation Framework | Accepted | 2026-02-24 | Granit.Validation | | [007](007-testcontainers/) | Testcontainers — Containerized Integration Tests | Accepted | 2026-02-24 | Integration Tests | | [008](008-smartformat-pluralization/) | SmartFormat.NET — CLDR Pluralization | Accepted | 2026-02-26 | Granit.Localization | | [009](009-scalar-api-documentation/) | Scalar.AspNetCore — Interactive API Documentation | Accepted | 2026-02-26 | Granit.ApiDocumentation | | [010](010-scriban-template-engine/) | Scriban — Text Template Engine | Accepted | 2026-02-27 | Granit.Templating.Scriban | | [011](011-closedxml-excel-generation/) | ClosedXML — Excel Spreadsheet Generation | Accepted | 2026-02-27 | Granit.DocumentGeneration.Excel | | [012](012-puppeteersharp-pdf-rendering/) | PuppeteerSharp — HTML to PDF Rendering | Accepted | 2026-02-28 | Granit.DocumentGeneration.Pdf | | [013](013-magicknet-image-processing/) | Magick.NET — Image Processing | Accepted | 2026-02-28 | Granit.Imaging.MagickNet | | [014](014-migration-shouldly/) | Migrate FluentAssertions to Shouldly | Accepted | 2026-02-28 | granit-dotnet | | [015](015-sep-csv-parsing/) | Sep — High-Performance CSV Parsing | Accepted | 2026-03-01 | Granit.DataExchange.Csv | | [016](016-sylvan-data-excel-parsing/) | Sylvan.Data.Excel — Streaming Excel File Reading | Accepted | 2026-03-01 | Granit.DataExchange.Excel | # Architecture Decision Records > Key technical decisions for the Granit TypeScript/React SDK Architecture Decision Records (ADRs) for the Granit Frontend SDK document significant technical decisions made during the development of the TypeScript and React packages. Each ADR follows the same template as backend ADRs: Context, Decision, Alternatives Considered, Justification, Consequences, and Re-evaluation Conditions. ## ADR index [Section titled “ADR index”](#adr-index) | # | Title | Status | Date | Scope | | ----------------------------- | --------------------------------------------------- | -------- | ---------- | -------------------------------- | | [001](001-source-direct/) | TypeScript Source-Direct — No Build Step | Accepted | 2026-02-27 | All `@granit/*` packages | | [002](002-pnpm-workspace/) | pnpm Workspace Monorepo | Accepted | 2026-02-27 | granit-front | | [003](003-react-19/) | React 19 as Minimum Version | Accepted | 2026-02-27 | All React packages | | [004](004-headless-packages/) | Headless Packages — Hooks Only, UI in Consumer Apps | Accepted | 2026-03-06 | All `@granit/*` packages | | [005](005-keycloak/) | Keycloak as Authentication Provider | Accepted | 2026-02-27 | @granit/authentication | | [006](006-tanstack-query/) | TanStack Query for Data Fetching | Accepted | 2026-03-04 | All React data-fetching packages | | [007](007-vitest/) | Vitest as Test Runner | Accepted | 2026-02-27 | granit-front | | [008](008-opentelemetry/) | OpenTelemetry for Distributed Tracing | Accepted | 2026-03-04 | @granit/tracing | # ADR-001: TypeScript Source-Direct — No Build Step > Export raw TypeScript source files via package.json exports, relying on Vite for on-the-fly transpilation > **Date:** 2026-02-27 **Authors:** Jean-Francois Meyers **Scope:** All `@granit/*` packages ## Context [Section titled “Context”](#context) The `@granit/*` packages are consumed exclusively by Vite-based applications. In local development, packages are linked via `pnpm link:` and resolved through Vite aliases. Vite transpiles TypeScript on-the-fly via esbuild. Maintaining a build step (`tsc`, `tsup`, `rollup`) for each package would introduce: * An additional `watch` process during development * A propagation delay for modifications reaching applications * Configuration complexity (source maps, declaration files, dual CJS/ESM) * A risk of desynchronization between source and compiled artifacts ## Decision [Section titled “Decision”](#decision) The `@granit/*` packages export their raw `.ts` source files directly via the `exports` field in `package.json`: ```json { "exports": { ".": "./src/index.ts" } } ``` No `dist/` directory is generated or committed. Consumer applications resolve imports via Vite aliases pointing to the sources. A `publishConfig` with `dist/` exports and a `tsup` build step is provided for npm publication to GitHub Packages. ## Alternatives considered [Section titled “Alternatives considered”](#alternatives-considered) ### Option 1: Source-direct with Vite transpilation (selected) [Section titled “Option 1: Source-direct with Vite transpilation (selected)”](#option-1-source-direct-with-vite-transpilation-selected) * **Advantage**: instant HMR, zero build configuration, direct source maps * **Disadvantage**: coupled to Vite (or any bundler capable of TypeScript transpilation) ### Option 2: tsup/rollup watch mode [Section titled “Option 2: tsup/rollup watch mode”](#option-2-tsuprollup-watch-mode) * **Advantage**: standard npm distribution, compatible with any consumer * **Disadvantage**: additional `watch` process per package, propagation delay, dual CJS/ESM complexity, source map indirection ### Option 3: tsc —watch with project references [Section titled “Option 3: tsc —watch with project references”](#option-3-tsc-watch-with-project-references) * **Advantage**: official TypeScript tooling, incremental compilation * **Disadvantage**: slow watch mode on large workspaces, declaration file management, no tree-shaking ## Justification [Section titled “Justification”](#justification) | Criterion | Source-direct | tsup/rollup watch | tsc —watch | | ------------------- | -------------------------- | --------------------------- | ------------------- | | HMR latency | Instant | \~1-3s per change | \~2-5s per change | | Dev configuration | None | Per-package config | tsconfig references | | Source maps | Direct to source | Through dist/ layer | Through dist/ layer | | Refactoring support | Full (IDE resolves source) | Partial (may resolve dist/) | Good | | CI type-check | `pnpm tsc --noEmit` | `pnpm tsc --noEmit` | `pnpm tsc --build` | | npm publication | Requires `tsup` build | Ready | Ready | ## Packages used [Section titled “Packages used”](#packages-used) | Package | Role | | -------------------- | ----------------------------------------------------- | | `tsup` | Build step for npm publication only (`publishConfig`) | | `esbuild` (via Vite) | On-the-fly TypeScript transpilation in development | ## Consequences [Section titled “Consequences”](#consequences) ### Positive [Section titled “Positive”](#positive) * Instant HMR: modifications in a package are reflected immediately in the application * Zero build configuration in development * Direct source maps to the original code (no intermediate layer) * Fluid refactoring: TypeScript tools (rename, find references) traverse packages seamlessly * Simplified CI: `pnpm tsc --noEmit` is sufficient for type checking ### Negative [Section titled “Negative”](#negative) * Coupled to Vite: consumer applications must use a bundler capable of TypeScript source transpilation * Publication: a build step is required before npm publication, creating two consumption modes (source-direct vs dist) * Compatibility: packages are not directly usable by a Node.js project without transpilation ## Re-evaluation conditions [Section titled “Re-evaluation conditions”](#re-evaluation-conditions) This decision should be re-evaluated if: * A consumer application needs to use a bundler that cannot transpile TypeScript (unlikely) * The number of packages grows beyond a point where Vite’s on-the-fly transpilation becomes a performance bottleneck * TypeScript natively supports running `.ts` files without transpilation (Node.js `--strip-types`) ## References [Section titled “References”](#references) * Vite TypeScript support: * tsup: # ADR-002: pnpm Workspace Monorepo > Use pnpm as the exclusive package manager for the monorepo with workspace:* protocol for internal dependencies > **Date:** 2026-02-27 **Authors:** Jean-Francois Meyers **Scope:** granit-front ## Context [Section titled “Context”](#context) The Granit frontend framework is composed of multiple packages with inter-package dependencies. The package manager must: * Manage a multi-package workspace with local symbolic links * Efficiently resolve shared dependencies (hoisting) * Offer fast resolution and installation performance * Support the `workspace:*` protocol for internal dependencies ## Decision [Section titled “Decision”](#decision) Use **pnpm** as the exclusive package manager for the monorepo. Configuration in `pnpm-workspace.yaml`: ```yaml packages: - 'packages/@granit/*' ``` Inter-package dependencies use `workspace:*` in `peerDependencies`: ```json { "peerDependencies": { "@granit/api-client": "workspace:*" } } ``` ## Alternatives considered [Section titled “Alternatives considered”](#alternatives-considered) ### Option 1: pnpm (selected) [Section titled “Option 1: pnpm (selected)”](#option-1-pnpm-selected) * **Advantage**: content-addressable store, isolated `node_modules`, native `workspace:*` protocol, fast installs * **Disadvantage**: less widespread than npm, pnpm-specific lock file ### Option 2: npm workspaces [Section titled “Option 2: npm workspaces”](#option-2-npm-workspaces) * **Advantage**: zero additional tooling (ships with Node.js) * **Disadvantage**: aggressive hoisting creates phantom dependencies, no `workspace:*` protocol, slower performance ### Option 3: Yarn Berry (PnP) [Section titled “Option 3: Yarn Berry (PnP)”](#option-3-yarn-berry-pnp) * **Advantage**: Plug’n’Play eliminates `node_modules`, zero-installs * **Disadvantage**: PnP complicates Vite and TypeScript tool integration, zero-installs bloat the repository ## Justification [Section titled “Justification”](#justification) | Criterion | pnpm | npm workspaces | Yarn Berry | | ---------------------- | -------------------------- | ---------------------- | -------------------------- | | Dependency isolation | Strict (non-flat) | Hoisted (phantom deps) | Strict (PnP) | | Install performance | Fast (content-addressable) | Moderate | Fast (zero-install) | | `workspace:*` protocol | Native | Not supported | Supported | | Vite compatibility | Works out of the box | Works | Requires PnP configuration | | Cross-package commands | `pnpm -r exec`, `--filter` | `npm -ws exec` | `yarn workspaces foreach` | | Community adoption | Growing rapidly | Universal | Declining | ## Consequences [Section titled “Consequences”](#consequences) ### Positive [Section titled “Positive”](#positive) * Strict isolation: no phantom dependencies thanks to pnpm’s non-flat `node_modules` * Performance: content-addressable store shared across projects, near-instant installs after the first run * `workspace:*`: automatic resolution of local packages with version replacement at publication * `pnpm -r exec`: command execution across all packages (lint, tsc) * `--filter`: targeted execution on a specific package ### Negative [Section titled “Negative”](#negative) * Adoption: pnpm is less widespread than npm, which may surprise new contributors * Lock file: `pnpm-lock.yaml` is incompatible with other managers, imposing pnpm on all contributors * Overrides: pnpm-specific syntax in `package.json` (`pnpm.overrides`) ## Re-evaluation conditions [Section titled “Re-evaluation conditions”](#re-evaluation-conditions) This decision should be re-evaluated if: * npm workspaces gains native `workspace:*` support and strict isolation * A new package manager emerges with compelling advantages over pnpm * The pnpm project is abandoned or maintenance slows significantly ## References [Section titled “References”](#references) * pnpm: * pnpm workspaces: # ADR-003: React 19 as Minimum Version > Require React 19 as the minimum peer dependency to leverage React Compiler, use() hook, and Actions > **Date:** 2026-02-27 **Authors:** Jean-Francois Meyers **Scope:** All `@granit/*` React packages ## Context [Section titled “Context”](#context) The `@granit/*` packages declare `react: "^19.0.0"` in their `peerDependencies`. This requires consumer applications to use React 19 as a minimum. React 19 introduces several features leveraged by the framework: * **React Compiler**: automatic re-render optimization (replaces manual `useMemo`/`useCallback`) * **`use()` hook**: read promises and contexts during render * **Actions**: native integration with forms and mutations * **Suspense improvements**: native streaming SSR support * **`ref` as prop**: eliminates the need for `forwardRef` in wrapper components ## Decision [Section titled “Decision”](#decision) Set React 19 as the minimum version in all `@granit/*` packages that have a peer dependency on React: ```json { "peerDependencies": { "react": "^19.0.0" } } ``` React 18 and earlier versions are not supported. ## Alternatives considered [Section titled “Alternatives considered”](#alternatives-considered) ### Option 1: React 19 minimum (selected) [Section titled “Option 1: React 19 minimum (selected)”](#option-1-react-19-minimum-selected) * **Advantage**: modern APIs without conditions or polyfills, React Compiler benefits, simpler codebase * **Disadvantage**: excludes applications still on React 18 ### Option 2: React 18+ with conditional features [Section titled “Option 2: React 18+ with conditional features”](#option-2-react-18-with-conditional-features) * **Advantage**: broader compatibility * **Disadvantage**: conditional branches for multiple major versions, cannot use new APIs unconditionally, maintenance burden ### Option 3: React 18 minimum [Section titled “Option 3: React 18 minimum”](#option-3-react-18-minimum) * **Advantage**: maximum compatibility * **Disadvantage**: cannot leverage React 19 features, technical debt from day one ## Justification [Section titled “Justification”](#justification) | Criterion | React 19 min | React 18+ conditional | React 18 min | | ------------------- | ----------------- | ------------------------- | ------------- | | API surface | Full React 19 | Partial (conditional) | React 18 only | | React Compiler | Automatic | Not available | Not available | | Code complexity | Simple | Higher (version branches) | Simple | | Forward-looking | Yes | Partial | No | | Consumer constraint | React 19 required | React 18+ | React 18+ | ## Consequences [Section titled “Consequences”](#consequences) ### Positive [Section titled “Positive”](#positive) * Modern API: framework code uses new APIs without conditions or polyfills * Performance: automatic benefit from React Compiler in consumer applications * Simplicity: no conditional branches for multiple React major versions * Forward-looking: all consumer applications are already on React 19 ### Negative [Section titled “Negative”](#negative) * Exclusion: applications still on React 18 cannot use `@granit/*` packages * Ecosystem: some third-party libraries may not yet support React 19 (transitory risk) ## Re-evaluation conditions [Section titled “Re-evaluation conditions”](#re-evaluation-conditions) This decision should be re-evaluated if: * A major consumer application cannot upgrade to React 19 * React 20 introduces breaking changes that require multi-version support ## References [Section titled “References”](#references) * React 19 release notes: * React Compiler: # ADR-004: Headless Packages — Hooks Only, UI in Consumer Apps > Framework packages export only hooks, types, providers, and utilities — no React components > **Date:** 2026-03-06 **Authors:** Jean-Francois Meyers **Scope:** All `@granit/*` packages ## Context [Section titled “Context”](#context) Initially, the monorepo contained UI packages that exposed React components (buttons, modals, tables, etc.) alongside business hooks. This approach caused several problems: * **Tight coupling** between business logic and visual presentation * **Design divergence**: different consumer applications use different design systems (components, tokens, spacing) * **Duplication**: each application ended up wrapping UI components to adapt them to its design system * **Heavy UI dependencies**: Tailwind CSS, lucide-react, shadcn/ui had to be aligned between the framework and applications A refactoring (2026-03-06) eliminated the UI layer from the framework. ## Decision [Section titled “Decision”](#decision) The `@granit/*` packages are strictly **headless**: they export only hooks, types, providers, and utility functions. No React component (JSX) is exported by the framework. UI components live exclusively in consumer applications, each implementing its own design system. ## Alternatives considered [Section titled “Alternatives considered”](#alternatives-considered) ### Option 1: Headless packages (selected) [Section titled “Option 1: Headless packages (selected)”](#option-1-headless-packages-selected) * **Advantage**: clean separation between logic and presentation, no UI dependency in the framework, stable TypeScript API contract * **Disadvantage**: component duplication across consumer applications ### Option 2: Shared component library with theming [Section titled “Option 2: Shared component library with theming”](#option-2-shared-component-library-with-theming) * **Advantage**: consistent UI across applications, single implementation * **Disadvantage**: theme configuration complexity, design system lock-in, heavy dependencies in the framework ### Option 3: Headless + optional UI package [Section titled “Option 3: Headless + optional UI package”](#option-3-headless--optional-ui-package) * **Advantage**: best of both worlds — headless core with optional UI * **Disadvantage**: double maintenance burden, risk of UI package becoming mandatory over time, version alignment complexity ## Justification [Section titled “Justification”](#justification) | Criterion | Headless | Shared components | Headless + optional UI | | ---------------------- | ---------------- | ---------------------- | ---------------------- | | Logic/UI separation | Complete | Coupled | Complete | | Design freedom | Full | Constrained | Full | | Framework dependencies | Minimal | Heavy (Tailwind, etc.) | Minimal core | | Test complexity | Low (hooks only) | Higher (DOM, styles) | Low core | | Component duplication | Yes | No | Partial | | Maintenance | Low | High | Medium | ## Consequences [Section titled “Consequences”](#consequences) ### Positive [Section titled “Positive”](#positive) * Clean separation between logic (framework) and presentation (application) * Design freedom: each application chooses its design system without constraint * Fewer dependencies: packages no longer need Tailwind, lucide-react, or any component library * Simplified testing: testing hooks is simpler than testing components (no DOM, no styles) * Stable API: the public interface is a TypeScript contract (types + hook signatures), independent of visual rendering ### Negative [Section titled “Negative”](#negative) * Component duplication: consumer applications implement their own table, export modal, etc. * No cross-app UI consistency: the framework does not guarantee visual homogeneity between applications * Initial effort: each new application must implement the UI layer for each `@granit/*` package it uses ## Re-evaluation conditions [Section titled “Re-evaluation conditions”](#re-evaluation-conditions) This decision should be re-evaluated if: * All consumer applications converge on the same design system * A headless component library (e.g. Radix, Ark UI) provides sufficient abstraction to share UI without design coupling * The duplication cost exceeds the coupling cost ## References [Section titled “References”](#references) * Headless UI pattern: * TanStack Table (headless reference): # ADR-005: Keycloak as Authentication Provider > Use Keycloak for OIDC/OAuth 2.0 authentication to meet HDS compliance and enable self-hosted European deployment > **Date:** 2026-02-27 **Authors:** Jean-Francois Meyers **Scope:** @granit/authentication, @granit/react-authentication, @granit/react-authorization ## Context [Section titled “Context”](#context) The platform operates in a healthcare data hosting (HDS) context that imposes strict authentication requirements: * **OpenID Connect / OAuth 2.0**: standard protocols required * **Complete audit**: connection and session traceability * **Multi-tenant**: realm isolation per tenant * **HDS compliance**: the provider must be hostable in France on private infrastructure (no US cloud) * **Open source**: no commercial lock-in ## Decision [Section titled “Decision”](#decision) Use **Keycloak** as the authentication provider via the following packages: * `@granit/authentication` — OIDC types (framework-agnostic) * `@granit/react-authentication` — Keycloak init hook, auth context factory, mock provider * `@granit/react-authorization` — Permission checking hooks (RBAC) The packages encapsulate `keycloak-js` and expose: * A React authentication context (`BaseAuthContextType`) * Integration hooks (`useAuth`, `useKeycloakInit`, `usePermissions`) * A mock provider for tests and local development * A 401 interceptor for revoked session handling The `BaseAuthContextType` is an extensible interface: consumer applications add their own fields (e.g. `register`, `hasAdminRole`). ## Alternatives considered [Section titled “Alternatives considered”](#alternatives-considered) ### Option 1: Keycloak (selected) [Section titled “Option 1: Keycloak (selected)”](#option-1-keycloak-selected) * **Advantage**: open source (Apache 2.0), self-hosted, standard protocols, CNCF project, large community, realm-per-tenant isolation * **Disadvantage**: operational burden (deployment, updates, realm backup, monitoring), `keycloak-js` API sometimes unstable between major versions ### Option 2: Auth0 [Section titled “Option 2: Auth0”](#option-2-auth0) * **Advantage**: fully managed SaaS, excellent developer experience * **Disadvantage**: **incompatible with HDS** — hosted in the US, Cloud Act applies, commercial lock-in ### Option 3: Azure AD B2C [Section titled “Option 3: Azure AD B2C”](#option-3-azure-ad-b2c) * **Advantage**: native Azure integration, managed service * **Disadvantage**: Azure dependency, US Cloud Act applies, complex configuration for custom flows ### Option 4: Custom OIDC implementation [Section titled “Option 4: Custom OIDC implementation”](#option-4-custom-oidc-implementation) * **Advantage**: full control, no external dependency * **Disadvantage**: enormous implementation effort, security risk, no community review, certification burden ## Justification [Section titled “Justification”](#justification) | Criterion | Keycloak | Auth0 | Azure AD B2C | Custom | | --------------------- | -------------------- | ---------------- | ----------------- | ---------------- | | HDS compliance | Yes (self-hosted EU) | No (US) | No (US Cloud Act) | Yes | | Open source | Yes (Apache 2.0) | No | No | Yes | | OIDC/OAuth 2.0 | Native | Native | Native | Manual | | Multi-tenant (realms) | Native | Per-tenant plans | Partial | Manual | | Operational cost | Self-hosted | SaaS fee | Azure fee | Development cost | | Community | CNCF, Red Hat | Large | Large | None | ## Packages used [Section titled “Packages used”](#packages-used) | Package | Role | | ------------- | ------------------------------------ | | `keycloak-js` | Official Keycloak JavaScript adapter | ## Consequences [Section titled “Consequences”](#consequences) ### Positive [Section titled “Positive”](#positive) * HDS compliance: Keycloak is self-hosted on private European infrastructure * Standards: OpenID Connect, OAuth 2.0, SAML 2.0 supported natively * Extensibility: themes, SPIs, identity federation * Community: CNCF project, active Red Hat maintenance * Isolation: one realm per tenant, no shared data ### Negative [Section titled “Negative”](#negative) * Operations: Keycloak must be deployed and maintained (updates, realm backup, monitoring) * Complexity: realm, client, and role configuration is non-trivial * `keycloak-js`: the official client library has occasionally unstable APIs between major versions ## Re-evaluation conditions [Section titled “Re-evaluation conditions”](#re-evaluation-conditions) This decision should be re-evaluated if: * A European managed identity service emerges with HDS certification * Keycloak maintenance burden becomes disproportionate * `keycloak-js` is deprecated in favor of a generic OIDC client ## References [Section titled “References”](#references) * Keycloak: * keycloak-js: # ADR-006: TanStack Query for Data Fetching > Use TanStack Query v5 as the data fetching layer with intelligent caching, structured query keys, and typed mutations > **Date:** 2026-03-04 **Authors:** Jean-Francois Meyers **Scope:** All `@granit/react-*` packages with data fetching ## Context [Section titled “Context”](#context) The data-fetching React packages (`@granit/react-querying`, `@granit/react-data-exchange`, `@granit/react-authorization`, `@granit/react-authentication-api-keys`, etc.) need a caching and synchronization layer with: * **Intelligent cache**: avoid duplicate requests, targeted invalidation * **Pagination**: native support for page/pageSize pagination * **Mutations**: optimistic updates with cache invalidation * **Retry and refetch**: network error resilience * **DevTools**: cache inspection during development ## Decision [Section titled “Decision”](#decision) Use **TanStack Query v5** (`@tanstack/react-query ^5.0.0`) as the data fetching layer. The dependency is declared in `peerDependencies`: ```json { "peerDependencies": { "@tanstack/react-query": "^5.0.0" } } ``` Public hooks (`usePermissions`, `useApiKeys`, `useIdentityCapabilities`, etc.) encapsulate `useQuery` and `useMutation` from TanStack Query. ## Alternatives considered [Section titled “Alternatives considered”](#alternatives-considered) ### Option 1: TanStack Query v5 (selected) [Section titled “Option 1: TanStack Query v5 (selected)”](#option-1-tanstack-query-v5-selected) * **Advantage**: rich API, typed query keys, mutations with invalidation, complete DevTools, large adoption, Orval compatibility * **Disadvantage**: peer dependency, \~13 kB gzip, learning curve ### Option 2: SWR (Vercel) [Section titled “Option 2: SWR (Vercel)”](#option-2-swr-vercel) * **Advantage**: simpler API, smaller bundle * **Disadvantage**: no structured mutations, no typed query keys, limited DevTools, less suitable for complex CRUD operations ### Option 3: Custom hooks (useState + useEffect) [Section titled “Option 3: Custom hooks (useState + useEffect)”](#option-3-custom-hooks-usestate--useeffect) * **Advantage**: zero dependency, full control * **Disadvantage**: reinventing cache, deduplication, pagination, and invalidation is a major effort with subtle bug risks ## Justification [Section titled “Justification”](#justification) | Criterion | TanStack Query | SWR | Custom hooks | | ----------------- | ---------------------------- | ----------- | ------------ | | Cache management | Excellent | Good | Manual | | Query key typing | Native | Manual | Manual | | Mutation support | `useMutation` + invalidation | Manual | Manual | | Pagination | Built-in | Partial | Manual | | DevTools | Complete | Basic | None | | Bundle size | \~13 kB gzip | \~4 kB gzip | 0 kB | | Ecosystem (Orval) | Compatible | Partial | N/A | ## Consequences [Section titled “Consequences”](#consequences) ### Positive [Section titled “Positive”](#positive) * Shared cache: all queries share the same `QueryClient`, enabling cross-invalidation * Structured query keys: key factories (`permissionKeys`, `apiKeyKeys`) ensure key consistency across hooks * Typed mutations: `useMutation` with `onSuccess` / `onError` / `onSettled` for business flows (export, import, workflow transitions) * DevTools: cache inspection, query replay in development * Ecosystem: compatible with Orval for automatic hook generation ### Negative [Section titled “Negative”](#negative) * Peer dependency: consumer applications must install and configure `@tanstack/react-query` (QueryClientProvider) * Bundle size: \~13 kB gzip (acceptable for an SPA) * Learning curve: concepts of stale time, gc time, invalidation, and query keys can be complex for new contributors ## Re-evaluation conditions [Section titled “Re-evaluation conditions”](#re-evaluation-conditions) This decision should be re-evaluated if: * React introduces a built-in data fetching primitive that replaces TanStack Query * TanStack Query v6 introduces breaking changes that require major migration effort * A significantly lighter alternative emerges with equivalent features ## References [Section titled “References”](#references) * TanStack Query: * Query key factories: # ADR-007: Vitest as Test Runner > Use Vitest for TypeScript unit testing with native ESM support and zero transpilation configuration > **Date:** 2026-02-27 **Authors:** Jean-Francois Meyers **Scope:** granit-front ## Context [Section titled “Context”](#context) The monorepo needs a unit test framework capable of: * Executing TypeScript tests without prior transpilation * Supporting ESM (`"type": "module"` in `package.json`) * Offering a performant watch mode for development * Generating coverage reports (lcov, html) * Working in a pnpm multi-package workspace ## Decision [Section titled “Decision”](#decision) Use **Vitest** (v4) as the test framework for all packages in the monorepo: ```bash pnpm test # watch mode (development) pnpm test:coverage # v8 coverage (CI) ``` Tests are co-located with source code: * `src/**/*.test.ts` for simple unit tests * `src/__tests__/` for more complex test suites The minimum coverage target is 80% on all new code. ## Alternatives considered [Section titled “Alternatives considered”](#alternatives-considered) ### Option 1: Vitest (selected) [Section titled “Option 1: Vitest (selected)”](#option-1-vitest-selected) * **Advantage**: uses the same transformation pipeline as Vite (esbuild), native ESM and TypeScript support, Jest-compatible API, fast file-system-based watch mode * **Disadvantage**: younger ecosystem than Jest, some Jest plugins have no Vitest equivalent ### Option 2: Jest [Section titled “Option 2: Jest”](#option-2-jest) * **Advantage**: industry standard, extensive plugin ecosystem * **Disadvantage**: experimental and unstable ESM support, TypeScript configuration requires `ts-jest` or `@swc/jest`, slow watch mode on large workspaces ### Option 3: Node.js native test runner [Section titled “Option 3: Node.js native test runner”](#option-3-nodejs-native-test-runner) * **Advantage**: zero dependency, ships with Node.js * **Disadvantage**: limited API, no watch mode, no coverage integration, no mocking framework ## Justification [Section titled “Justification”](#justification) | Criterion | Vitest | Jest | Node.js test runner | | ------------------------ | ------------------------ | ------------------- | ------------------- | | TypeScript transpilation | esbuild (automatic) | ts-jest / @swc/jest | Manual | | ESM support | Native | Experimental | Native | | Watch mode | File-system-based (fast) | Polling (slow) | None | | API compatibility | Jest-compatible | Standard | Limited | | Coverage | v8 (built-in) | istanbul / v8 | Experimental | | Workspace-aware | `vitest.workspace.ts` | Custom config | No | ## Consequences [Section titled “Consequences”](#consequences) ### Positive [Section titled “Positive”](#positive) * Zero transpilation configuration: Vitest uses esbuild, same as Vite — `.ts` files are transpiled on-the-fly * Native ESM: no issues with `import`/`export`, `import.meta`, etc. * Performance: parallel execution, file-system-based watch mode (no polling) * Jest-compatible API: `describe`, `it`, `expect`, `vi.fn()` — minimal learning curve for developers coming from Jest * v8 coverage: lcov and html reports without additional dependencies * Workspace-aware: `vitest.workspace.ts` for multi-package configuration ### Negative [Section titled “Negative”](#negative) * Ecosystem: some Jest plugins have no Vitest equivalent (marginal risk) * Maturity: Vitest is more recent than Jest (mitigated by massive adoption in the Vite ecosystem) ## Re-evaluation conditions [Section titled “Re-evaluation conditions”](#re-evaluation-conditions) This decision should be re-evaluated if: * Jest achieves stable ESM support with comparable performance * Vitest development slows or the project is abandoned * A new test runner emerges with compelling advantages ## References [Section titled “References”](#references) * Vitest: * Vitest workspace: # ADR-008: OpenTelemetry for Distributed Tracing > Implement OpenTelemetry browser tracing for end-to-end request correlation between frontend and .NET backend > **Date:** 2026-03-04 **Authors:** Jean-Francois Meyers **Scope:** @granit/tracing, @granit/react-tracing, @granit/logger-otlp ## Context [Section titled “Context”](#context) The platform requires distributed tracing to: * Follow requests end-to-end (frontend → .NET backend → database) * Diagnose performance issues * Correlate frontend logs with backend traces * Feed an observability backend (Grafana Tempo) The .NET backend already uses OpenTelemetry (see [ADR-001](/architecture/adr/001-observability/)). The frontend tracing must use the same standard for seamless correlation. ## Decision [Section titled “Decision”](#decision) Use **OpenTelemetry** via the `@granit/tracing` package, which encapsulates: * `WebTracerProvider` for SDK initialization * `OTLPTraceExporter` (HTTP) for trace export * Auto-instrumentations: `fetch`, `XMLHttpRequest`, `document-load` * `TracingProvider` (React context) for activation in the component tree * `useTracer` and `useSpan` hooks for custom spans * `getTraceContext` for non-React integration (e.g. `@granit/logger-otlp`) The `@granit/logger-otlp` package extends `@granit/logger` to inject trace IDs into logs, enabling log-to-trace correlation in Grafana. All OpenTelemetry dependencies are declared as `peerDependencies`: ```json { "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/sdk-trace-web": "^2.6.0", "@opentelemetry/exporter-trace-otlp-http": "^0.213.0", "@opentelemetry/instrumentation-fetch": "^0.213.0", "@opentelemetry/instrumentation-xml-http-request": "^0.213.0", "@opentelemetry/instrumentation-document-load": "^0.57.0", "@opentelemetry/resources": "^2.6.0", "@opentelemetry/semantic-conventions": "^1.40.0", "@opentelemetry/context-zone": "^2.6.0" } } ``` ## Alternatives considered [Section titled “Alternatives considered”](#alternatives-considered) ### Option 1: OpenTelemetry (selected) [Section titled “Option 1: OpenTelemetry (selected)”](#option-1-opentelemetry-selected) * **Advantage**: CNCF standard, vendor-agnostic, same protocol as .NET backend, auto-instrumentation for HTTP and page load, self-hostable collector * **Disadvantage**: 9 peer dependencies, web SDK less mature than server-side ### Option 2: Proprietary solution (Datadog, New Relic) [Section titled “Option 2: Proprietary solution (Datadog, New Relic)”](#option-2-proprietary-solution-datadog-new-relic) * **Advantage**: complete SaaS solution (logs + traces + metrics + APM) * **Disadvantage**: **incompatible with data sovereignty** — US company subject to Cloud Act, high cost per host/GB ### Option 3: Custom trace propagation [Section titled “Option 3: Custom trace propagation”](#option-3-custom-trace-propagation) * **Advantage**: minimal dependencies, full control * **Disadvantage**: non-standard, no auto-instrumentation, no ecosystem tooling, no correlation with backend traces ## Justification [Section titled “Justification”](#justification) | Criterion | OpenTelemetry | Datadog / New Relic | Custom | | -------------------- | ------------------------- | ---------------------------- | ----------- | | Sovereignty | Self-hosted collector | No (US) | Self-hosted | | CNCF standard | Yes | Partial (proprietary agents) | No | | Backend correlation | Same protocol (.NET OTel) | Proprietary | Manual | | Auto-instrumentation | fetch, XHR, page load | Full | None | | Cost | Infrastructure only | Per-host/GB | Development | ## Packages used [Section titled “Packages used”](#packages-used) | Package | Role | | ------------------------------------------------- | ----------------------------------------- | | `@opentelemetry/api` | Core tracing API | | `@opentelemetry/sdk-trace-web` | Browser tracer provider | | `@opentelemetry/exporter-trace-otlp-http` | OTLP HTTP trace export | | `@opentelemetry/instrumentation-fetch` | Auto-instrumentation for `fetch()` | | `@opentelemetry/instrumentation-xml-http-request` | Auto-instrumentation for XHR | | `@opentelemetry/instrumentation-document-load` | Page load tracing | | `@opentelemetry/resources` | Resource metadata (service name, version) | | `@opentelemetry/semantic-conventions` | Standard attribute names | | `@opentelemetry/context-zone` | Zone.js context propagation | ## Consequences [Section titled “Consequences”](#consequences) ### Positive [Section titled “Positive”](#positive) * Standard compliance: same OTLP protocol as the .NET backend, seamless end-to-end correlation * Vendor-agnostic: the observability backend can be changed without modifying frontend code * Automatic correlation: trace IDs propagate automatically between frontend and backend * Auto-instrumentation: HTTP requests (fetch, XHR) and page load are traced automatically * Data sovereignty: the OTLP collector is self-hosted on European infrastructure ### Negative [Section titled “Negative”](#negative) * Peer dependency count: 9 OpenTelemetry packages add configuration overhead in consumer applications * Web SDK maturity: the OpenTelemetry web SDK is less mature than server-side SDKs (Node.js, .NET) * Performance: instrumentation adds slight overhead to HTTP requests (mitigated by graceful degradation when the collector is absent) ## Re-evaluation conditions [Section titled “Re-evaluation conditions”](#re-evaluation-conditions) This decision should be re-evaluated if: * The OpenTelemetry web SDK is deprecated in favor of a different approach * Browser-native tracing APIs emerge (e.g. Performance Observer extensions) * The number of peer dependencies becomes a significant maintenance burden ## References [Section titled “References”](#references) * OpenTelemetry JS: * OpenTelemetry Web SDK: * ADR-001 (.NET Observability): [ADR-001](/architecture/adr/001-observability/) # ADR-001: Observability Stack — Serilog + OpenTelemetry > Adoption of Serilog and OpenTelemetry for structured logging, distributed tracing, and metrics with data sovereignty compliance > **Date:** 2026-02-21 **Authors:** Jean-Francois Meyers **Scope:** granit-dotnet (Granit.Observability) ## Context [Section titled “Context”](#context) The Granit framework provides the `Granit.Observability` module which encapsulates the configuration of structured logging, distributed tracing and metrics. The choice of instrumentation libraries determines: * **ISO 27001 traceability**: structured timestamped logs retained for 3 years * **Distributed tracing**: request correlation across modules, Wolverine messages and HTTP calls * **Metrics**: performance monitoring and alerting * **Data sovereignty**: no telemetry data must leave European infrastructure Observability data is exported via the OTLP protocol to a self-hosted Grafana stack: **Loki** (logs), **Tempo** (traces), **Mimir** (metrics). ## Decision [Section titled “Decision”](#decision) * **Logging**: Serilog (`Serilog.AspNetCore` + `Serilog.Sinks.OpenTelemetry`) * **Tracing & metrics**: OpenTelemetry .NET SDK (7 packages) * **Export**: OTLP (OpenTelemetry Protocol) to the Grafana stack ## Alternatives considered [Section titled “Alternatives considered”](#alternatives-considered) ### Option 1: Serilog + OpenTelemetry (selected) [Section titled “Option 1: Serilog + OpenTelemetry (selected)”](#option-1-serilog--opentelemetry-selected) * **Logging**: Serilog — structured logging, enrichers (context, tenant, user), OTLP sink to unify the pipeline * **Tracing**: OpenTelemetry — CNCF standard, automatic instrumentation (ASP.NET Core, HTTP, EF Core), W3C Trace Context propagation * **Export**: OTLP to Loki/Tempo/Mimir (self-hosted in Europe) ### Option 2: Microsoft.Extensions.Logging + OpenTelemetry only [Section titled “Option 2: Microsoft.Extensions.Logging + OpenTelemetry only”](#option-2-microsoftextensionslogging--opentelemetry-only) * **Advantage**: no third-party dependency for logging * **Disadvantage**: limited enrichment, no dedicated sink for advanced structured formats, less flexible configuration than Serilog (namespace filtering, destructuring) ### Option 3: NLog + OpenTelemetry [Section titled “Option 3: NLog + OpenTelemetry”](#option-3-nlog--opentelemetry) * **Advantage**: NLog is mature and performant * **Disadvantage**: less developed OTLP sink ecosystem than Serilog, XML configuration (vs fluent API), modern .NET community leans toward Serilog ### Option 4: Application Insights (Azure Monitor) [Section titled “Option 4: Application Insights (Azure Monitor)”](#option-4-application-insights-azure-monitor) * **Advantage**: native .NET integration, out-of-the-box dashboard * **Disadvantage**: **incompatible with data sovereignty** — data hosted on Azure (US Cloud Act), variable cost based on ingestion volume, vendor lock-in ### Option 5: Datadog / New Relic [Section titled “Option 5: Datadog / New Relic”](#option-5-datadog--new-relic) * **Advantage**: complete SaaS solution (logs + traces + metrics + APM) * **Disadvantage**: **incompatible with data sovereignty** — data outside EU (or EU region but US company subject to Cloud Act), high cost per host/GB ## Justification [Section titled “Justification”](#justification) | Criterion | Serilog + OTel | MEL + OTel | NLog + OTel | App Insights | Datadog | | -------------------- | -------------- | ----------- | ----------- | ------------ | --------- | | Sovereignty | Self-hosted | Self-hosted | Self-hosted | No (Azure) | No (US) | | CNCF standard | Yes (OTel) | Yes (OTel) | Yes (OTel) | Partial | Partial | | Log enrichment | Excellent | Basic | Good | Good | Excellent | | Native OTLP sink | Yes | No | Partial | N/A | N/A | | .NET community | Very large | Standard | Medium | Large | Medium | | Cost | Infra only | Infra only | Infra only | Variable | High | | ISO 27001 compliance | Yes | Yes | Yes | Risk | Risk | ## Packages used [Section titled “Packages used”](#packages-used) | Package | Role | | --------------------------------------------------- | ------------------------------------------- | | `Serilog.AspNetCore` | ASP.NET Core integration, request enrichers | | `Serilog.Sinks.OpenTelemetry` | Log export via OTLP | | `OpenTelemetry` | Core SDK | | `OpenTelemetry.Api` | Instrumentation API (ActivitySource, Meter) | | `OpenTelemetry.Extensions.Hosting` | `IHostBuilder` integration | | `OpenTelemetry.Instrumentation.AspNetCore` | Automatic HTTP request traces | | `OpenTelemetry.Instrumentation.Http` | Automatic `HttpClient` call traces | | `OpenTelemetry.Instrumentation.EntityFrameworkCore` | Automatic EF Core query traces | | `OpenTelemetry.Exporter.OpenTelemetryProtocol` | OTLP export to Loki/Tempo/Mimir | ## Consequences [Section titled “Consequences”](#consequences) ### Positive [Section titled “Positive”](#positive) * Sovereignty compliance: zero telemetry data leaving European hosting * CNCF standard: portability to any OTLP-compatible backend * Full correlation: logs, traces, and metrics via the same TraceId * Serilog contextual enrichment: tenant, user, module, correlation-id * Unified Grafana dashboard for the entire platform ### Negative [Section titled “Negative”](#negative) * Grafana stack maintenance (Loki, Tempo, Mimir) falls on the SRE team * Initial configuration more complex than a SaaS solution * Serilog is a third-party dependency (maintenance risk, although very stable) ## Re-evaluation conditions [Section titled “Re-evaluation conditions”](#re-evaluation-conditions) This decision should be re-evaluated if: * A European managed observability service emerges (ISO 27001 certified) * OpenTelemetry .NET SDK reaches feature parity with Serilog for structured logging * The maintenance burden of the self-hosted Grafana stack becomes disproportionate ## References [Section titled “References”](#references) * Initial commit: `52f1444` (2026-02-21) * Serilog: * OpenTelemetry .NET: # ADR-002: Redis via StackExchange.Redis — Distributed Cache > Selection of Redis via StackExchange.Redis as the distributed cache backend for L1+L2 caching > **Date:** 2026-02-21 **Authors:** Jean-Francois Meyers **Scope:** granit-dotnet (Granit.Caching, Granit.Caching.StackExchangeRedis, Granit.Caching.Hybrid) ## Context [Section titled “Context”](#context) The Granit framework provides cache abstractions (`Granit.Caching`) and requires a distributed cache backend for: * **Performance**: reducing latency for frequent reads (settings, translations, templates, permissions) * **Scalability**: shared cache across Kubernetes instances (sticky sessions impossible in an ISO 27001 context — high availability required) * **Idempotency**: HTTP idempotency key storage * **SignalR**: Redis backplane for real-time notifications The choice of cache backend determines the implementation of `Granit.Caching.StackExchangeRedis` and the L1+L2 pattern of `Granit.Caching.Hybrid`. ## Decision [Section titled “Decision”](#decision) **Redis** via **StackExchange.Redis** as the distributed cache backend (L2), combined with `Microsoft.Extensions.Caching.Hybrid` for the L1+L2 pattern. ## Alternatives considered [Section titled “Alternatives considered”](#alternatives-considered) ### Option 1: Redis via StackExchange.Redis (selected) [Section titled “Option 1: Redis via StackExchange.Redis (selected)”](#option-1-redis-via-stackexchangeredis-selected) * **License**: MIT (StackExchange.Redis) * **Advantage**: de facto standard, native `IDistributedCache` integration, HybridCache support, Pub/Sub for invalidation, SignalR backplane ### Option 2: Memcached [Section titled “Option 2: Memcached”](#option-2-memcached) * **Advantage**: simple, lightweight, performant for pure key-value * **Disadvantage**: no Pub/Sub, no advanced data structures, no persistence, no SignalR backplane ### Option 3: NCache [Section titled “Option 3: NCache”](#option-3-ncache) * **Advantage**: .NET native solution, advanced topologies * **Disadvantage**: commercial license, limited community, no standard HybridCache integration ### Option 4: Microsoft Garnet [Section titled “Option 4: Microsoft Garnet”](#option-4-microsoft-garnet) * **Advantage**: Redis protocol compatible, superior performance * **Disadvantage**: recent project (2024), no managed service, stability risk for ISO 27001 production use ## Justification [Section titled “Justification”](#justification) | Criterion | SE.Redis | Memcached | NCache | Garnet | | ------------------- | ----------------- | ----------- | ----------- | ---------- | | Client license | MIT | Apache-2.0 | Freemium | MIT | | IDistributedCache | Native MS | Third-party | Third-party | Compatible | | HybridCache .NET 10 | Yes | No | No | Compatible | | Pub/Sub | Yes | No | Yes | Yes | | SignalR backplane | Yes (MS official) | No | No | Untested | | Maturity | 10+ years | Mature | Mature | Recent | ## Consequences [Section titled “Consequences”](#consequences) ### Positive [Section titled “Positive”](#positive) * Native integration with Microsoft DI (`IDistributedCache`, `HybridCache`) * MIT client, stable and very widely adopted * Pub/Sub for cache invalidation and SignalR backplane * Transparent L1+L2 HybridCache pipeline via `Granit.Caching` ### Negative [Section titled “Negative”](#negative) * Redis is an additional infrastructure dependency to operate * Redis 7.4+ license (SSPL): to monitor if self-hosted * Complex object serialization requires a consistent strategy ## Re-evaluation conditions [Section titled “Re-evaluation conditions”](#re-evaluation-conditions) This decision should be re-evaluated if: * Microsoft Garnet reaches production maturity and offers a managed service * The Redis license (SSPL) becomes problematic for self-hosted deployment * Cache needs evolve toward a pattern incompatible with Redis (e.g. geographically distributed cache) ## References [Section titled “References”](#references) * Initial commit: `76378865` (2026-02-21) * StackExchange.Redis: # ADR-003: Testing Stack — xUnit v3, NSubstitute and Bogus > Selection of xUnit v3, NSubstitute, and Bogus as the foundational testing stack > **Date:** 2026-02-21 **Authors:** Jean-Francois Meyers **Scope:** granit-dotnet, consuming applications ## Context [Section titled “Context”](#context) The Granit framework applies the “tests are part of the DoD” principle: each package has a `*.Tests` project and no code can be shipped without test coverage. The choice of testing stack is therefore foundational for the entire platform. Requirements: * **Test framework**: parallelism, native CancellationToken, DI in tests, xUnit.v3 support for the new APIs * **Mocking**: dependency substitution (services, repositories, HTTP clients) with a clear API and no license issues * **Test data**: realistic and localized (FR) data generation * **Coverage**: code coverage collection for CI (Cobertura/OpenCover) * **CI**: result export in TRX/JUnit format for CI integration ## Decision [Section titled “Decision”](#decision) | Role | Library | License | | -------------- | ------------------- | ------------ | | Test framework | xUnit v3 | Apache-2.0 | | Mocking | NSubstitute | BSD-3-Clause | | Test data | Bogus | MIT | | Coverage | coverlet.collector | MIT | | CI report | JunitXml.TestLogger | MIT | ## Alternatives considered [Section titled “Alternatives considered”](#alternatives-considered) ### Test framework [Section titled “Test framework”](#test-framework) #### xUnit v3 (selected) [Section titled “xUnit v3 (selected)”](#xunit-v3-selected) * Native CancellationToken (`TestContext.Current.CancellationToken`) * Parallelism by default (test collections) * Dominant adoption in the .NET open source ecosystem * Native `IAsyncLifetime` support for async setup/teardown #### NUnit [Section titled “NUnit”](#nunit) * Mature framework, rich in attributes (`[TestCase]`, `[SetUp]`, `[TearDown]`) * Disadvantage: less natural parallelism, modern .NET community leans more toward xUnit, no native CancellationToken in tests #### MSTest [Section titled “MSTest”](#mstest) * Official Microsoft framework * Disadvantage: limited features compared to xUnit/NUnit, low adoption in .NET open source, less expressive API #### TUnit [Section titled “TUnit”](#tunit) * Recent framework based on source generators (no runtime reflection) * Disadvantage: young project (v1.x), single maintainer, limited ecosystem (Testcontainers, Verify primarily target xUnit/NUnit) * Re-evaluation planned via a future ADR when the project reaches sufficient maturity (cf. [ADR-014](014-migration-shouldly.md)) ### Mocking [Section titled “Mocking”](#mocking) #### NSubstitute (selected) [Section titled “NSubstitute (selected)”](#nsubstitute-selected) * Clear and readable API (`service.Method().Returns(value)`) * BSD-3-Clause — no license issues * No verbose `Setup`/`Verify` syntax #### Moq [Section titled “Moq”](#moq) * Most popular historical library * **License issue**: SponsorLink (v4.20+) injected telemetry code into builds, creating a compliance risk and a community trust crisis. Incompatible with GDPR/ISO 27001 security policy #### FakeItEasy [Section titled “FakeItEasy”](#fakeiteasy) * Pleasant fluent API (`A.CallTo(() => ...).Returns(...)`) * Disadvantage: more verbose syntax than NSubstitute, smaller community ### Test data [Section titled “Test data”](#test-data) #### Bogus (selected) [Section titled “Bogus (selected)”](#bogus-selected) * Realistic data generation with locales (fr, fr\_BE, en, etc.) * Fluent API (`new Faker().RuleFor(...)`) * Support for complex types and business rules #### AutoFixture [Section titled “AutoFixture”](#autofixture) * Automatic generation without configuration * Disadvantage: unrealistic data (random strings), less control over business values, less intuitive syntax #### Faker.NET [Section titled “Faker.NET”](#fakernet) * Port of Faker.js * Disadvantage: less rich API than Bogus, less maintained ## Justification [Section titled “Justification”](#justification) ### Test framework [Section titled “Test framework”](#test-framework-1) | Criterion | xUnit v3 | NUnit | MSTest | TUnit | | ------------------------ | ---------- | --------- | --------- | ---------- | | Native CancellationToken | Yes | No | No | Yes | | Default parallelism | Yes | Partial | Partial | Yes | | .NET OSS adoption | Dominant | Strong | Low | Emerging | | Maturity | 15+ years | 20+ years | 20+ years | < 2 years | | License | Apache-2.0 | MIT | MIT | Apache-2.0 | ### Mocking [Section titled “Mocking”](#mocking-1) | Criterion | NSubstitute | Moq | FakeItEasy | | ---------------- | ------------ | ------------------- | ---------- | | License | BSD-3-Clause | MIT (+ SponsorLink) | Apache-2.0 | | SponsorLink risk | No | Yes | No | | API conciseness | Excellent | Good | Medium | | Community | Large | Very large | Medium | ### Test data [Section titled “Test data”](#test-data-1) | Criterion | Bogus | AutoFixture | Faker.NET | | ------------------ | ---------------- | ----------- | --------- | | FR locales | Yes (fr, fr\_BE) | No | Partial | | Realistic data | Yes | No (random) | Yes | | Fluent API | Yes | Partial | No | | Active maintenance | Yes | Yes | Low | ## Consequences [Section titled “Consequences”](#consequences) ### Positive [Section titled “Positive”](#positive) * Coherent and modern stack, adopted by the majority of the .NET ecosystem * Zero license risk (no SponsorLink, no commercial license) * Native CancellationToken xUnit v3: interruptible tests, faster CI * Realistic and localized test data (French names, SIRET, etc.) * Cobertura coverage for SonarQube/SonarCloud and CI ### Negative [Section titled “Negative”](#negative) * Migration from another test framework would be costly if necessary * xUnit v3 is recent: some third-party tools may have a support lag * Bogus generates pseudo-random data (non-deterministic by default — use `Seed` for reproducibility) # ADR-004: Asp.Versioning — REST API Versioning > Adoption of Asp.Versioning for semantic REST API versioning with OpenAPI integration > **Date:** 2026-02-22 **Authors:** Jean-Francois Meyers **Scope:** granit-dotnet (Granit.ApiVersioning) ## Context [Section titled “Context”](#context) The platform REST APIs must support versioning to allow contract evolution without breaking existing clients. This need is particularly critical in a healthcare context (ISO 27001) where third-party integrators (laboratories, EHR systems) have long update cycles. Versioning must be: * **Explicit**: each endpoint declares its version * **Negotiable**: the client chooses the version via URL, header or query string * **Documented**: versions appear in the OpenAPI spec (Scalar UI) ## Decision [Section titled “Decision”](#decision) **Asp.Versioning.Mvc** (+ ApiExplorer) for semantic API versioning. ## Alternatives considered [Section titled “Alternatives considered”](#alternatives-considered) ### Option 1: Asp.Versioning (selected) [Section titled “Option 1: Asp.Versioning (selected)”](#option-1-aspversioning-selected) * **License**: MIT (.NET Foundation) * **Advantage**: official .NET Foundation package (formerly Microsoft.AspNetCore.Mvc.Versioning), support for URL segment (`/api/v1/...`), header (`api-version`), query string (`?api-version=1`), media type. ApiExplorer integration for OpenAPI * **Maturity**: 8+ years, migrated from the historical Microsoft package ### Option 2: Manual URL versioning (routing convention) [Section titled “Option 2: Manual URL versioning (routing convention)”](#option-2-manual-url-versioning-routing-convention) * **Advantage**: zero dependency, simple for basic cases * **Disadvantage**: no version negotiation, no sunset policies, code duplication between versions, no automatic OpenAPI integration ### Option 3: Custom naming convention (namespace-based) [Section titled “Option 3: Custom naming convention (namespace-based)”](#option-3-custom-naming-convention-namespace-based) * **Advantage**: clear code organization by namespace/version * **Disadvantage**: requires a homegrown framework, no standard, maintenance and documentation burden on the team ## Justification [Section titled “Justification”](#justification) | Criterion | Asp.Versioning | Manual URL | Custom | | ------------------ | --------------------------- | ---------- | --------- | | .NET standard | Yes (.NET Foundation) | No | No | | Version modes | URL, header, QS, media type | URL only | Variable | | Integrated OpenAPI | Yes (ApiExplorer) | No | No | | Sunset policies | Yes | No | No | | Maintenance effort | None (community) | High | Very high | ## Consequences [Section titled “Consequences”](#consequences) ### Positive [Section titled “Positive”](#positive) * .NET ecosystem standard, abundant documentation * Multi-modal versioning (URL segment by default in Granit) * Automatic integration with Scalar UI via ApiExplorer * Sunset headers for progressive deprecation of old versions ### Negative [Section titled “Negative”](#negative) * Preview version (10.0.0-preview\.1) for .NET 10 — to monitor * Initial configuration required (default convention in `GranitApiVersioningModule`) # ADR-005: Wolverine + Cronos — Messaging, CQRS and Scheduling > Selection of Wolverine for messaging and CQRS with PostgreSQL transport, and Cronos for cron scheduling > **Date:** 2026-02-22 **Authors:** Jean-Francois Meyers **Scope:** granit-dotnet (Granit.Wolverine, Granit.Wolverine.Postgresql, Granit.BackgroundJobs) ## Context [Section titled “Context”](#context) The platform requires: * **Asynchronous messaging**: sending commands and events between modules (domain events, integration events) with delivery guarantees * **Transactional outbox**: messages must be persisted in the same transaction as business changes (eventual consistency without loss) * **CQRS**: command/query separation with an integrated mediator * **Background jobs**: execution of recurring tasks (synchronization, cleanup, reports) with cron scheduling and multi-instance resilience * **No external broker**: for the MVP, avoid the operational complexity of a RabbitMQ or Kafka — PostgreSQL must suffice as transport Cronos is used as the cron expression parser in the `Granit.BackgroundJobs` module for recurring job scheduling. ## Decision [Section titled “Decision”](#decision) * **Wolverine** (WolverineFx) as message bus, mediator and handler framework with PostgreSQL outbox * **Cronos** as cron expression parser for background job scheduling ## Alternatives considered [Section titled “Alternatives considered”](#alternatives-considered) ### Messaging / Mediator [Section titled “Messaging / Mediator”](#messaging--mediator) #### Wolverine (selected) [Section titled “Wolverine (selected)”](#wolverine-selected) * **License**: MIT (JasperFx) * **Outbox**: native EF Core transactional (`WolverineFx.EntityFrameworkCore`) * **Transport**: native PostgreSQL (`WolverineFx.Postgresql`) — no broker required * **Pipeline**: composable middleware (validation, retry, DLQ, logging) * **Handlers**: convention-based (no interface to implement), auto-discovery * **Integration**: native FluentValidation middleware, multi-tenancy support #### MassTransit [Section titled “MassTransit”](#masstransit) * **License**: Apache-2.0 * **Advantage**: very mature, large community, multi-transport support (RabbitMQ, Azure SB, Amazon SQS, in-memory) * **Disadvantage**: requires an external broker for production (RabbitMQ minimum), more verbose configuration, EF Core outbox available but less integrated than Wolverine, no native PostgreSQL-as-transport #### MediatR [Section titled “MediatR”](#mediatr) * **License**: Apache-2.0 * **Advantage**: simple, lightweight, pure mediator pattern * **Disadvantage**: no outbox, no transport, no retry/DLQ, no scheduling — only an in-process mediator. Requires combining with another tool for asynchronous messaging #### Brighter [Section titled “Brighter”](#brighter) * **License**: MIT * **Advantage**: outbox support, middleware pipeline * **Disadvantage**: smaller community, less comprehensive documentation, more complex configuration than Wolverine #### NServiceBus [Section titled “NServiceBus”](#nservicebus) * **License**: commercial (Particular Software) * **Advantage**: complete enterprise solution, saga support, monitoring * **Disadvantage**: paid license, incompatible with the project’s OSS strategy ### Scheduling / Cron [Section titled “Scheduling / Cron”](#scheduling--cron) #### Cronos (selected) [Section titled “Cronos (selected)”](#cronos-selected) * **License**: MIT * **Advantage**: lightweight and fast cron parser, optional seconds support, next occurrence calculation without state * **Usage**: integrated in `RecurringJobAttribute` and `CronSchedulerAgent` #### Quartz.NET [Section titled “Quartz.NET”](#quartznet) * **Advantage**: complete scheduler with persistence, clustering, advanced triggers * **Disadvantage**: oversized (complete scheduler when Wolverine already handles execution), responsibility duplication, heavy configuration #### Hangfire [Section titled “Hangfire”](#hangfire) * **License**: LGPL-3.0 (core), commercial (Pro) * **Advantage**: built-in dashboard, recurring jobs, automatic retry * **Disadvantage**: overlap with Wolverine (transport, retry, DLQ), restrictive license for advanced features #### NCrontab [Section titled “NCrontab”](#ncrontab) * **Advantage**: simple and lightweight cron parser * **Disadvantage**: no seconds support, less modern API than Cronos, reduced maintenance ## Justification [Section titled “Justification”](#justification) ### Messaging [Section titled “Messaging”](#messaging) | Criterion | Wolverine | MassTransit | MediatR | Brighter | NServiceBus | | -------------------- | --------- | ----------- | ----------- | -------- | ----------- | | License | MIT | Apache-2.0 | Apache-2.0 | MIT | Commercial | | EF Core outbox | Native | Yes | No | Yes | Yes | | PostgreSQL transport | Native | No | N/A | No | No | | Broker required | No | Yes (prod) | N/A | Yes | Yes | | Middleware pipeline | Yes | Yes | Yes | Yes | Yes | | FluentValidation | Native | Third-party | Third-party | No | No | | Convention-based | Yes | Partial | No | No | No | | Multi-tenancy | Yes | Yes | No | No | Yes | ### Scheduling [Section titled “Scheduling”](#scheduling) | Criterion | Cronos | Quartz.NET | Hangfire | NCrontab | | --------- | ----------- | ------------------ | ------------------ | ----------- | | License | MIT | Apache-2.0 | LGPL/Commercial | Apache-2.0 | | Scope | Parser only | Complete scheduler | Complete scheduler | Parser only | | Seconds | Optional | Yes | No | No | | Weight | Very light | Heavy | Medium | Light | ## Consequences [Section titled “Consequences”](#consequences) ### Positive [Section titled “Positive”](#positive) * No external broker: PostgreSQL suffices as transport (operational simplicity) * Transactional outbox: zero message loss, guaranteed eventual consistency * Unified Wolverine pipeline: validation, retry, DLQ, logging, tracing * Lightweight Cronos: just a parser, orchestration is handled by Wolverine * MIT license for the entire stack ### Negative [Section titled “Negative”](#negative) * Wolverine is less known than MassTransit (smaller community) * Dependency on JasperFx (primary maintainer: Jeremy D. Miller) * If a broker need arises (RabbitMQ, Kafka), migration required (Wolverine supports RabbitMQ and Azure SB, but not Kafka natively) * PostgreSQL-as-transport has throughput limits vs a dedicated broker # ADR-006: FluentValidation — Business Validation Framework > Adoption of FluentValidation for composable business validation with Wolverine pipeline integration > **Date:** 2026-02-24 **Authors:** Jean-Francois Meyers **Scope:** granit-dotnet (Granit.Validation, Granit.Wolverine) ## Context [Section titled “Context”](#context) The platform requires a validation framework for: * **Business validation**: complex and composable rules (address, SIRET, IBAN, email, locale) with standardized error codes * **Wolverine integration**: automatic command validation before execution via the middleware pipeline (`WolverineFx.FluentValidation`) * **Error codes**: mapping to RFC 7807 ProblemDetails for HTTP responses * **Extensibility**: custom reusable validators across modules ## Decision [Section titled “Decision”](#decision) **FluentValidation** as the business validation framework. ## Alternatives considered [Section titled “Alternatives considered”](#alternatives-considered) ### Option 1: FluentValidation (selected) [Section titled “Option 1: FluentValidation (selected)”](#option-1-fluentvalidation-selected) * **License**: Apache-2.0 * **Advantage**: composable fluent API (`RuleFor(x => x.Email).EmailAddress()`), native Wolverine integration, large community, easy custom validators * **Maturity**: 15+ years, de facto standard for .NET validation ### Option 2: DataAnnotations only [Section titled “Option 2: DataAnnotations only”](#option-2-dataannotations-only) * **Advantage**: native .NET, zero dependency, integrated with model binding * **Disadvantage**: limited to simple validations (attributes), no composition, no complex conditional validation, no Wolverine middleware integration, non-standardized error codes ### Option 3: MiniValidation [Section titled “Option 3: MiniValidation”](#option-3-minivalidation) * **License**: MIT * **Advantage**: lightweight, based on DataAnnotations with extensions * **Disadvantage**: no composable rules, no Wolverine integration, limited community, does not cover complex business cases ### Option 4: Custom validation (no framework) [Section titled “Option 4: Custom validation (no framework)”](#option-4-custom-validation-no-framework) * **Advantage**: full control, no dependency * **Disadvantage**: considerable development and maintenance effort, reinventing the wheel, no standards, no middleware pipeline ## Justification [Section titled “Justification”](#justification) | Criterion | FluentValidation | DataAnnotations | MiniValidation | Custom | | ---------------------- | --------------------- | --------------- | -------------- | ------ | | License | Apache-2.0 | Native .NET | MIT | N/A | | Composable rules | Yes | No | No | Manual | | Wolverine middleware | Native | No | No | Manual | | Conditional validation | Yes (When/Unless) | No | No | Manual | | Error codes | Yes (WithErrorCode) | Limited | Limited | Manual | | Community | Very large | Standard | Low | N/A | | RFC 7807 mapping | Via Granit.AspNetCore | Manual | Manual | Manual | ## Consequences [Section titled “Consequences”](#consequences) ### Positive [Section titled “Positive”](#positive) * Declarative and readable validation in each module * Wolverine integration: commands are validated before execution (DLQ on failure) * Standardized Granit error codes (e.g. `VALIDATION.EMAIL.INVALID`) * Reusable validators across packages (`AddressValidator`, `SiretValidator`) * Automatic mapping to ProblemDetails RFC 7807 via `GranitExceptionHandler` ### Negative [Section titled “Negative”](#negative) * Third-party dependency for validation (risk of major breaking changes) * Partial duplication with DataAnnotations for simple cases (Granit convention: use FluentValidation even for simple cases, for consistency) # ADR-007: Testcontainers — Containerized Integration Tests > Adoption of Testcontainers for ephemeral PostgreSQL containers in integration tests > **Date:** 2026-02-24 **Authors:** Jean-Francois Meyers **Scope:** granit-dotnet (Granit.Wolverine.Postgresql.IntegrationTests) ## Context [Section titled “Context”](#context) Granit integration tests require a real PostgreSQL database to validate DBMS-specific behaviors: EF Core migrations, Wolverine outbox, multi-tenant global filters, JSONB queries, etc. In-memory alternatives (EF Core InMemory, SQLite) do not faithfully reproduce PostgreSQL behavior and mask bugs that only appear in production. ## Decision [Section titled “Decision”](#decision) **Testcontainers** (`Testcontainers.PostgreSql`) to orchestrate ephemeral PostgreSQL containers in integration tests. ## Alternatives considered [Section titled “Alternatives considered”](#alternatives-considered) ### Option 1: Testcontainers (selected) [Section titled “Option 1: Testcontainers (selected)”](#option-1-testcontainers-selected) * **License**: MIT * **Advantage**: real PostgreSQL container started on demand, complete isolation per test, automatic cleanup, .NET fluent API, xUnit support via `IAsyncLifetime` * **CI**: compatible with GitHub Actions (service containers) and GitLab CI ### Option 2: EF Core InMemory [Section titled “Option 2: EF Core InMemory”](#option-2-ef-core-inmemory) * **Advantage**: fast, zero infrastructure dependency * **Disadvantage**: no real SQL (no migrations, no FK constraints, no JSONB, no transactions), false sense of confidence, bugs masked in production ### Option 3: SQLite (EF Core) [Section titled “Option 3: SQLite (EF Core)”](#option-3-sqlite-ef-core) * **Advantage**: real SQL without a server, fast * **Disadvantage**: SQL dialect different from PostgreSQL (no JSONB, no schemas, different types), non-portable migrations, different transactional behavior ### Option 4: Shared PostgreSQL test database [Section titled “Option 4: Shared PostgreSQL test database”](#option-4-shared-postgresql-test-database) * **Advantage**: no Docker, speed (no container startup) * **Disadvantage**: shared state between tests (difficult isolation), manual cleanup, non-reproducible CI (depends on external server), conflicts between developers ## Justification [Section titled “Justification”](#justification) | Criterion | Testcontainers | InMemory | SQLite | Shared DB | | ------------------- | -------------------- | --------- | -------- | --------- | | PostgreSQL fidelity | Full | None | Partial | Full | | Isolation | Per test | Per test | Per test | Difficult | | CI reproducibility | Yes | Yes | Yes | No | | Speed | Medium (\~3-5s init) | Very fast | Fast | Fast | | Zero external infra | Yes (Docker) | Yes | Yes | No | | EF Core migrations | Yes | No | Partial | Yes | ## Consequences [Section titled “Consequences”](#consequences) ### Positive [Section titled “Positive”](#positive) * Tests faithful to production behavior (real PostgreSQL) * Complete isolation: each test suite has its own database * Reproducible CI without external dependency * Early detection of DBMS-related bugs (types, constraints, transactions) ### Negative [Section titled “Negative”](#negative) * Requires Docker on development machines and in CI * Container startup time (\~3-5 seconds per test suite) * Higher memory consumption than in-memory alternatives # ADR-008: SmartFormat.NET — CLDR Pluralization > Selection of SmartFormat.NET for CLDR-compliant pluralization in the localization system > **Date:** 2026-02-26 **Authors:** Jean-Francois Meyers **Scope:** granit-dotnet (Granit.Localization) ## Context [Section titled “Context”](#context) The `Granit.Localization` module provides a modular JSON localization system. Pluralization is a critical feature for multilingual applications: CLDR rules (Unicode Common Locale Data Repository) define pluralization categories that vary by language (French: singular/plural, Arabic: 6 forms, etc.). ## Decision [Section titled “Decision”](#decision) **SmartFormat.NET** for pluralization in the localization system. ## Alternatives considered [Section titled “Alternatives considered”](#alternatives-considered) ### Option 1: SmartFormat.NET (selected) [Section titled “Option 1: SmartFormat.NET (selected)”](#option-1-smartformatnet-selected) * **License**: MIT * **Advantage**: complete CLDR support (all languages), familiar syntax (`{0:plural:...}`), lightweight (\~50 KB), no native dependency (ICU), extensible via custom formatters * **Maturity**: 12+ years, active ### Option 2: ICU4N [Section titled “Option 2: ICU4N”](#option-2-icu4n) * **Advantage**: official ICU implementation for .NET, MessageFormat support * **Disadvantage**: native ICU dependency (cross-platform deployment issues, \~30 MB size), complex API, limited .NET documentation ### Option 3: MessageFormat.NET [Section titled “Option 3: MessageFormat.NET”](#option-3-messageformatnet) * **Advantage**: implementation of the ICU MessageFormat standard * **Disadvantage**: poorly maintained project, limited community, insufficient documentation, no complete CLDR support ### Option 4: Custom pluralization [Section titled “Option 4: Custom pluralization”](#option-4-custom-pluralization) * **Advantage**: full control, zero dependency * **Disadvantage**: reimplementing CLDR rules (200+ languages) is a considerable effort and error-prone, long-term maintenance burden ### Option 5: gettext (.po files) [Section titled “Option 5: gettext (.po files)”](#option-5-gettext-po-files) * **Advantage**: industry standard for localization, mature tooling * **Disadvantage**: .po file format incompatible with Granit’s JSON system, requires a complete overhaul of the localization pipeline, limited .NET ecosystem ## Justification [Section titled “Justification”](#justification) | Criterion | SmartFormat | ICU4N | MessageFormat | Custom | gettext | | ------------------ | ----------- | --------- | ------------- | ------ | ------------ | | License | MIT | MIT | MIT | N/A | GPL/LGPL | | Complete CLDR | Yes | Yes | Partial | No | Yes | | Native dependency | No | Yes (ICU) | No | No | No | | Size | \~50 KB | \~30 MB | \~20 KB | 0 | Variable | | .NET documentation | Good | Low | Low | N/A | Low | | JSON integration | Easy | Possible | Possible | N/A | Incompatible | ## Consequences [Section titled “Consequences”](#consequences) ### Positive [Section titled “Positive”](#positive) * Correct pluralization for all languages without native dependency * Concise and readable syntax in JSON translation files * Lightweight: no significant impact on package size * Extensible: custom formatters for specific business cases ### Negative [Section titled “Negative”](#negative) * SmartFormat has its own syntax (not a pure ICU MessageFormat standard) * If a switch to ICU MessageFormat becomes necessary, translation file migration required # ADR-009: Scalar.AspNetCore — Interactive API Documentation > Adoption of Scalar as the interactive OpenAPI documentation UI replacing Swagger UI > **Date:** 2026-02-26 **Authors:** Jean-Francois Meyers **Scope:** granit-dotnet (Granit.ApiDocumentation) ## Context [Section titled “Context”](#context) The platform REST APIs require an interactive documentation interface for developers and integrators. Since .NET 9, Microsoft removed Swashbuckle (Swagger UI) from the default template in favor of `Microsoft.AspNetCore.OpenApi` for OpenAPI spec generation. The documentation UI choice must natively integrate with the new .NET 10 OpenAPI pipeline without depending on Swashbuckle. ## Decision [Section titled “Decision”](#decision) **Scalar.AspNetCore** as the interactive OpenAPI documentation UI. ## Alternatives considered [Section titled “Alternatives considered”](#alternatives-considered) ### Option 1: Scalar (selected) [Section titled “Option 1: Scalar (selected)”](#option-1-scalar-selected) * **License**: MIT * **Advantage**: native `Microsoft.AspNetCore.OpenApi` integration (.NET 9+), modern and responsive UI, built-in “try it”, customizable themes, API search, OpenAPI 3.1 support * **Configuration**: `app.MapScalarApiReference()` — one line ### Option 2: Swagger UI (Swashbuckle) [Section titled “Option 2: Swagger UI (Swashbuckle)”](#option-2-swagger-ui-swashbuckle) * **Advantage**: historical standard, very wide adoption * **Disadvantage**: Swashbuckle is **abandoned** (last release 2023, removed from .NET 9 template by Microsoft), dependency on NSwag for spec generation (overlap with `Microsoft.AspNetCore.OpenApi`), dated UI ### Option 3: ReDoc [Section titled “Option 3: ReDoc”](#option-3-redoc) * **License**: MIT * **Advantage**: elegant static documentation, three-column layout * **Disadvantage**: no native “try it” (read-only), requires custom integration with the .NET OpenAPI pipeline, less interactive ### Option 4: RapiDoc [Section titled “Option 4: RapiDoc”](#option-4-rapidoc) * **License**: MIT * **Advantage**: lightweight, web component, themes * **Disadvantage**: smaller community, no official .NET integration, irregular maintenance ### Option 5: Stoplight Elements [Section titled “Option 5: Stoplight Elements”](#option-5-stoplight-elements) * **License**: Apache-2.0 * **Advantage**: modern UI, API design support * **Disadvantage**: SaaS-oriented (Stoplight Studio), non-official .NET integration, advanced features are paid ## Justification [Section titled “Justification”](#justification) | Criterion | Scalar | Swagger UI | ReDoc | RapiDoc | Elements | | ------------------- | ------ | ---------- | ------ | --------- | ---------- | | License | MIT | MIT | MIT | MIT | Apache-2.0 | | .NET 10 integration | Native | Abandoned | Custom | Custom | Custom | | Interactive try-it | Yes | Yes | No | Yes | Yes | | Modern UI | Yes | No | Yes | Yes | Yes | | Active maintenance | Yes | No (2023) | Yes | Irregular | Yes | | .NET configuration | 1 line | \~10 lines | Custom | Custom | Custom | | OpenAPI 3.1 | Yes | Partial | Yes | Yes | Yes | ## Consequences [Section titled “Consequences”](#consequences) ### Positive [Section titled “Positive”](#positive) * Native integration with `Microsoft.AspNetCore.OpenApi` (zero Swashbuckle) * Modern UI with try-it, search, and themes * Minimal configuration (`app.MapScalarApiReference()`) * Compatible with Asp.Versioning (multiple versions in the spec) * Active maintenance and regular releases ### Negative [Section titled “Negative”](#negative) * Scalar is less known than Swagger UI (familiarization curve for the team) * Relatively recent project (2023) — less track record than Swagger UI * Some advanced features (mocking, testing) are in Scalar Cloud (SaaS) # ADR-010: Scriban — Text Template Engine > Selection of Scriban as the sandboxed text template engine for document generation > **Date:** 2026-02-27 **Authors:** Jean-Francois Meyers **Scope:** granit-dotnet (Granit.Templating.Scriban) ## Context [Section titled “Context”](#context) The `Granit.Templating` module provides a document generation pipeline: text template -> HTML rendering -> conversion to final format (PDF, Excel, etc.). The text template engine must: * **Security**: execute templates in a sandbox (no filesystem or network access) * **Extensibility**: custom functions, global variables (date, user, tenant) * **Performance**: template compilation, caching * **Syntax**: intuitive for non-developers (business operations) ## Decision [Section titled “Decision”](#decision) **Scriban** as the text template engine for document generation. ## Alternatives considered [Section titled “Alternatives considered”](#alternatives-considered) ### Option 1: Scriban (selected) [Section titled “Option 1: Scriban (selected)”](#option-1-scriban-selected) * **License**: BSD-2-Clause * **Advantage**: sandboxed by default (no system access), intuitive Liquid-like syntax, extensible (custom functions, `GlobalContext`), template compilation and caching, excellent performance (\~10x faster than Razor) * **Size**: lightweight (\~200 KB) ### Option 2: Razor (RazorLight) [Section titled “Option 2: Razor (RazorLight)”](#option-2-razor-razorlight) * **Advantage**: familiar C# syntax for .NET developers, powerful * **Disadvantage**: **not sandboxed** (full .NET runtime access — security risk if templates are user-editable), dependency on Roslyn compiler (heavy, \~20 MB), high compilation time, RazorLight is a less maintained third-party wrapper ### Option 3: Fluid (Liquid .NET) [Section titled “Option 3: Fluid (Liquid .NET)”](#option-3-fluid-liquid-net) * **License**: MIT * **Advantage**: .NET implementation of Liquid (Shopify standard), sandboxed, similar syntax to Scriban * **Disadvantage**: less performant than Scriban on benchmarks, more limited extensibility (no native `GlobalContext`), smaller .NET community ### Option 4: Handlebars.NET [Section titled “Option 4: Handlebars.NET”](#option-4-handlebarsnet) * **License**: MIT * **Advantage**: .NET port of Handlebars.js, logicless templates * **Disadvantage**: “logicless” too restrictive (no complex conditions, no advanced loops), extensibility via helpers only, lower performance than Scriban ### Option 5: Mustache (Stubble) [Section titled “Option 5: Mustache (Stubble)”](#option-5-mustache-stubble) * **License**: MIT * **Advantage**: multi-language standard, very simple * **Disadvantage**: too minimalistic (no filters, no functions, no expressions), unsuitable for complex document generation ## Justification [Section titled “Justification”](#justification) | Criterion | Scriban | Razor | Fluid | Handlebars | Mustache | | ---------------- | ----------------- | ------------------ | -------- | ---------- | ------------ | | License | BSD-2-Clause | MIT | MIT | MIT | MIT | | Sandbox | Yes (native) | No | Yes | Partial | Yes | | Extensibility | Excellent | Total (C#) | Good | Limited | Very limited | | Performance | Very fast | Slow (compilation) | Fast | Medium | Fast | | Intuitive syntax | Yes (Liquid-like) | C# (dev only) | Yes | Yes | Yes | | Size | \~200 KB | \~20 MB (Roslyn) | \~150 KB | \~100 KB | \~50 KB | | GlobalContext | Yes | No | No | No | No | ## Consequences [Section titled “Consequences”](#consequences) ### Positive [Section titled “Positive”](#positive) * Templates executed in a sandbox: no risk of arbitrary code execution * Liquid-like syntax accessible to business users * GlobalContext for enrichment variables (date, tenant, user) * Template caching and compilation for performance * Complete pipeline: Scriban -> HTML -> PDF (via PuppeteerSharp) ### Negative [Section titled “Negative”](#negative) * Scriban-specific syntax (not a standard like Liquid or Mustache) * BSD-2-Clause (very permissive, but less common than MIT/Apache-2.0) * Project maintained by an individual developer (Alexandre Mutel — also author of markdig, SharpDX, etc.) # ADR-011: ClosedXML — Excel Spreadsheet Generation > Selection of ClosedXML for Excel file generation from templates with MIT licensing > **Date:** 2026-02-27 **Authors:** Jean-Francois Meyers **Scope:** granit-dotnet (Granit.DocumentGeneration.Excel) ## Context [Section titled “Context”](#context) The `Granit.DocumentGeneration.Excel` module generates `.xlsx` spreadsheets from Excel templates. Use cases include: data exports, financial reports, dashboards, and regulatory documents. The library must support: * **Templates**: filling named cells in an existing `.xlsx` file * **Styling**: preserving styles, formulas and page layout from the template * **Performance**: generating large files (10,000+ rows) with streaming * **License**: compatible with commercial use without recurring costs ## Decision [Section titled “Decision”](#decision) **ClosedXML** for Excel file (.xlsx) generation. ## Alternatives considered [Section titled “Alternatives considered”](#alternatives-considered) ### Option 1: ClosedXML (selected) [Section titled “Option 1: ClosedXML (selected)”](#option-1-closedxml-selected) * **License**: MIT * **Advantage**: intuitive high-level API, .xlsx template support, styles/formulas/layout preserved, active maintenance, MIT * **Maturity**: 10+ years, large community ### Option 2: EPPlus [Section titled “Option 2: EPPlus”](#option-2-epplus) * **License**: **Polyform Noncommercial License** (v5+) — commercial use requires a **paid license** (\~$300/dev/year) * **Advantage**: very complete API, excellent performance, exhaustive documentation * **Disadvantage**: license change in 2020 (v5), recurring cost incompatible with the project’s OSS strategy ### Option 3: NPOI [Section titled “Option 3: NPOI”](#option-3-npoi) * **License**: Apache-2.0 * **Advantage**: .NET port of Apache POI, supports .xls and .xlsx * **Disadvantage**: low-level and verbose API (close to Java POI), insufficient .NET documentation, lower performance than ClosedXML and EPPlus, irregular maintenance ### Option 4: Open XML SDK (Microsoft) [Section titled “Option 4: Open XML SDK (Microsoft)”](#option-4-open-xml-sdk-microsoft) * **License**: MIT * **Advantage**: official Microsoft SDK, full access to the OOXML format * **Disadvantage**: **very low-level** API (direct OOXML XML manipulation), extreme verbosity for simple operations (filling a cell = \~20 lines), no native template support, no high-level style management ## Justification [Section titled “Justification”](#justification) | Criterion | ClosedXML | EPPlus | NPOI | Open XML SDK | | ---------------- | --------- | ---------------- | ---------- | ---------------- | | License | MIT | Commercial (v5+) | Apache-2.0 | MIT | | Cost | Free | \~$300/dev/year | Free | Free | | High-level API | Yes | Yes | No | No | | Template support | Yes | Yes | Partial | No | | Documentation | Good | Excellent | Low | Good (low-level) | | Performance | Good | Excellent | Medium | Good | | Maintenance | Active | Active | Irregular | Active (MS) | ## Consequences [Section titled “Consequences”](#consequences) ### Positive [Section titled “Positive”](#positive) * MIT license: no recurring cost, compatible with commercial use * Intuitive API: `worksheet.Cell("A1").Value = "Hello"` vs \~20 lines Open XML SDK * Template support: filling named cells in an existing .xlsx * Style, formula and layout preservation * Large community and documentation ### Negative [Section titled “Negative”](#negative) * Slightly lower performance than EPPlus on very large files * Some advanced features (charts, pivot tables) are less complete than in EPPlus * No native streaming support for very large files (in-memory loading — workaround possible with pagination) # ADR-012: PuppeteerSharp — HTML to PDF Rendering > Adoption of PuppeteerSharp with headless Chromium for pixel-perfect HTML to PDF conversion > **Date:** 2026-02-28 **Authors:** Jean-Francois Meyers **Scope:** granit-dotnet (Granit.DocumentGeneration.Pdf) ## Context [Section titled “Context”](#context) The `Granit.DocumentGeneration.Pdf` module converts HTML (generated by Scriban) into PDF documents. Use cases include: invoices, medical reports, certificates, and regulatory documents. Requirements: * **CSS fidelity**: pixel-perfect HTML/CSS rendering (flexbox, grid, @media print) * **PDF/A-3b**: compliance for long-term archiving (ISO 27001) and Factur-X * **Headers/footers**: dynamic headers and footers (pagination, date) * **Performance**: generation in < 2 seconds for a standard document ## Decision [Section titled “Decision”](#decision) **PuppeteerSharp** (headless Chromium) for HTML to PDF conversion. ## Alternatives considered [Section titled “Alternatives considered”](#alternatives-considered) ### Option 1: PuppeteerSharp (selected) [Section titled “Option 1: PuppeteerSharp (selected)”](#option-1-puppeteersharp-selected) * **License**: MIT * **Advantage**: perfect CSS fidelity (Chromium Blink engine), PDF/A-3b support (via post-processing), dynamic headers/footers, landscape/portrait, configurable margins, native async .NET API * **Pipeline**: Scriban -> HTML -> PuppeteerSharp -> PDF ### Option 2: QuestPDF [Section titled “Option 2: QuestPDF”](#option-2-questpdf) * **License**: **Community License** (free < $1M revenue, otherwise commercial) * **Advantage**: fluent C# API (code-first), no Chromium, lightweight * **Disadvantage**: no HTML rendering (C# API only — incompatible with the Scriban -> HTML pipeline), restrictive license for enterprises, no native PDF/A, learning curve for non-developers ### Option 3: iText7 [Section titled “Option 3: iText7”](#option-3-itext7) * **License**: **AGPL-3.0** (commercial use requires a paid license) * **Advantage**: reference library for PDF manipulation, native PDF/A, very mature * **Disadvantage**: AGPL license incompatible with a distributed framework (source code publication obligation), expensive commercial license, limited HTML rendering (pdfHTML paid add-on) ### Option 4: wkhtmltopdf [Section titled “Option 4: wkhtmltopdf”](#option-4-wkhtmltopdf) * **Advantage**: lightweight, WebKit rendering * **Disadvantage**: **abandoned project** (last release 2020), obsolete WebKit engine (no flexbox, grid), uncorrected security issues, no native .NET support (CLI wrapper) ### Option 5: Playwright (via Microsoft.Playwright) [Section titled “Option 5: Playwright (via Microsoft.Playwright)”](#option-5-playwright-via-microsoftplaywright) * **License**: Apache-2.0 * **Advantage**: Chromium engine like PuppeteerSharp, modern API, maintained by Microsoft * **Disadvantage**: much heavier package (\~200 MB vs \~50 MB — includes Firefox and WebKit), testing-oriented (no native PDF generation API, requires workarounds), higher initialization overhead ## Justification [Section titled “Justification”](#justification) | Criterion | PuppeteerSharp | QuestPDF | iText7 | wkhtmltopdf | Playwright | | ---------------------- | ------------------ | ------------ | --------------- | ----------------- | --------------- | | License | MIT | Freemium | AGPL/Commercial | MIT | Apache-2.0 | | HTML rendering | Chromium (perfect) | No (C# only) | Limited (paid) | WebKit (obsolete) | Chromium | | Scriban->HTML pipeline | Yes | No | Partial | Yes | Yes | | PDF/A-3b | Post-processing | No | Native | No | Post-processing | | Package size | \~50 MB | \~5 MB | \~10 MB | \~40 MB | \~200 MB | | Maintenance | Active | Active | Active | Abandoned | Active (MS) | | Performance | Good | Excellent | Good | Medium | Good | ## Consequences [Section titled “Consequences”](#consequences) ### Positive [Section titled “Positive”](#positive) * Perfect CSS fidelity: PDF is identical to browser rendering * Unified pipeline: Scriban (template) -> HTML -> PuppeteerSharp (PDF) * PDF/A-3b support via post-processing for ISO 27001 archiving and Factur-X * MIT: no license constraints * Native async .NET API with Chromium lifecycle management ### Negative [Section titled “Negative”](#negative) * Chromium dependency: \~50 MB binary to download on first launch * Memory consumption: one Chromium process per rendering instance (browser pool recommended) * Chromium startup time: \~1-2 seconds on first call (mitigated by browser pool) * Chromium requires system dependencies in CI/production (libgbm, libatk, etc. — handled via Docker image) # ADR-013: Magick.NET — Image Processing > Selection of Magick.NET (ImageMagick wrapper) for cross-platform image processing with Apache-2.0 licensing > **Date:** 2026-02-28 **Authors:** Jean-Francois Meyers **Scope:** granit-dotnet (Granit.Imaging.MagickNet) ## Context [Section titled “Context”](#context) The `Granit.Imaging` module provides an image processing pipeline for the platform: resizing, format conversion, compression, watermarking, and metadata extraction. Use cases include: profile photos, scanned documents, medical images (excluding DICOM), and compliance watermarks. Requirements: * **Formats**: JPEG, PNG, WebP, TIFF, BMP, GIF (minimum) * **Transformations**: resize, crop, rotate, watermark, format conversion * **Cross-platform**: Linux (production K8s) and Windows (development) * **License**: compatible with commercial use ## Decision [Section titled “Decision”](#decision) **Magick.NET** (.NET wrapper for ImageMagick, Q8 variant) for image processing. ## Alternatives considered [Section titled “Alternatives considered”](#alternatives-considered) ### Option 1: Magick.NET-Q8-AnyCPU (selected) [Section titled “Option 1: Magick.NET-Q8-AnyCPU (selected)”](#option-1-magicknet-q8-anycpu-selected) * **License**: Apache-2.0 * **Advantage**: 200+ supported formats, complete transformations (resize, crop, watermark, compression), cross-platform (native wrapper), Q8 variant (8 bits/channel — optimized memory), .NET fluent API, very mature (ImageMagick: 25+ years) ### Option 2: ImageSharp (SixLabors) [Section titled “Option 2: ImageSharp (SixLabors)”](#option-2-imagesharp-sixlabors) * **License**: **Six Labors Split License** (v3+) — commercial use requires a **paid license** (enterprise $2,500/year) * **Advantage**: 100% managed .NET (no native dependency), modern API, excellent performance, composable pipeline * **Disadvantage**: license change in 2023 (v3), recurring cost for production features, fewer formats than Magick.NET ### Option 3: SkiaSharp [Section titled “Option 3: SkiaSharp”](#option-3-skiasharp) * **License**: MIT * **Advantage**: Skia engine (Google), performant for 2D rendering, cross-platform * **Disadvantage**: rendering/drawing-oriented (not image processing), low-level API for common transformations (resize, watermark), native Skia dependency (\~10 MB), not all formats supported (no TIFF, partial WebP) ### Option 4: System.Drawing.Common [Section titled “Option 4: System.Drawing.Common”](#option-4-systemdrawingcommon) * **License**: MIT (Microsoft) * **Advantage**: native .NET Framework, familiar API * **Disadvantage**: **Windows-only** since .NET 6 (no Linux support — blocking for K8s production), deprecated by Microsoft, known memory leaks, not thread-safe ## Justification [Section titled “Justification”](#justification) | Criterion | Magick.NET | ImageSharp | SkiaSharp | System.Drawing | | -------------- | -------------- | ---------------- | ------------ | ---------------------- | | License | Apache-2.0 | Commercial (v3+) | MIT | MIT | | Cost | Free | $2,500/year | Free | Free | | Formats | 200+ | \~30 | \~15 | \~10 | | Cross-platform | Yes (native) | Yes (managed) | Yes (native) | Windows only | | Watermark | Native | Native | Manual | Manual | | Maturity | 25+ years (IM) | 7+ years | 10+ years | 20+ years (deprecated) | | Thread-safe | Yes | Yes | Partial | No | ## Consequences [Section titled “Consequences”](#consequences) ### Positive [Section titled “Positive”](#positive) * 200+ supported formats, covering all current and future use cases * Apache-2.0: no license cost (ImageSharp would be \~$2,500/year) * Cross-platform: works on Linux (K8s production) and Windows (dev) * Q8 variant: optimized memory (8 bits/channel sufficient for web) * Native watermark for compliance watermarks ### Negative [Section titled “Negative”](#negative) * Native ImageMagick dependency (libMagickWand) — handled by the AnyCPU package but may cause issues in some Docker environments (requires system libraries) * Less modern API than ImageSharp (C API wrapper) * Larger package size than managed alternatives (\~15 MB) * Historical ImageMagick vulnerabilities (ImageTragick 2016) — mitigated by built-in security policies and regular updates # ADR-014: Migrate FluentAssertions to Shouldly > Migration from FluentAssertions to Shouldly due to license change incompatible with commercial use > **Date:** 2026-02-28 **Authors:** Jean-Francois Meyers **Scope:** granit-dotnet, consuming applications ## Context [Section titled “Context”](#context) FluentAssertions is the assertion library used in all test projects (`*.Tests`) of granit-dotnet and consuming applications. Starting with **version 7.x**, FluentAssertions was acquired by **Xceed Software** and changed its license: from **MIT** to the **Xceed Community License Agreement**. This new license **prohibits commercial use** without purchasing a paid license. The platform is a commercial product (healthcare, ISO 27001 certification). Using FluentAssertions 8.x in this context constitutes a **license non-compliance**. ## Decision [Section titled “Decision”](#decision) **Migrate to Shouldly** as the assertion library for all test projects. ## Alternatives considered [Section titled “Alternatives considered”](#alternatives-considered) ### Option 1: Shouldly (selected) [Section titled “Option 1: Shouldly (selected)”](#option-1-shouldly-selected) * **License**: Apache-2.0 (permissive, compatible with commercial use) * **Maturity**: stable project, actively maintained, large community * **Impact**: assertion replacement only, the xUnit test framework remains unchanged * **API**: concise and readable syntax (`actual.ShouldBe(expected)`) ### Option 2: Downgrade to FluentAssertions 6.x (MIT) [Section titled “Option 2: Downgrade to FluentAssertions 6.x (MIT)”](#option-2-downgrade-to-fluentassertions-6x-mit) * **License**: MIT (last version under permissive license) * **Advantage**: no code migration required * **Disadvantage**: end-of-life version, no more security updates or patches. Incompatible with a long-term maintenance strategy ### Option 3: TUnit (complete xUnit + FluentAssertions replacement) [Section titled “Option 3: TUnit (complete xUnit + FluentAssertions replacement)”](#option-3-tunit-complete-xunit--fluentassertions-replacement) * **License**: Apache-2.0 * **Advantage**: modern test framework with built-in assertions (`Assert.That(x).IsEqualTo(42)`), native parallelism via source generators (no runtime reflection), native DI in tests, attribute-based lifecycle (`[Before]`, `[After]`) without `IAsyncLifetime` * **Performance**: significantly faster than xUnit on large test suites thanks to source generators and default parallelism * **Disadvantage**: implies complete test framework replacement (xUnit -> TUnit), not just assertions. Migration: attributes (`[Fact]` -> `[Test]`, `[Theory]` -> `[Test]` + `[Arguments]`), lifecycle, DI, CI runner **Discussion (2026-02-28)**: since the Granit framework is not yet in production and the test volume is low, the migration cost to TUnit would currently be minimal. However: 1. **xUnit v3 just released** with substantial improvements (parallelism, native `CancellationToken`, better DI) that reduce the performance gap 2. **Asymmetric risk**: if TUnit stagnates (young project, single maintainer), the framework ends up on a niche framework without ecosystem or broad community support. The .NET ecosystem (Testcontainers, Verify, etc.) primarily targets xUnit/NUnit 3. **Separation of concerns**: decoupling the assertion choice (immediate license problem) from the framework choice (distinct architectural decision) allows addressing the urgency without speculative bets **Verdict**: TUnit remains an option to re-evaluate when the project reaches sufficient maturity (v2+, significant adoption, official Testcontainers documentation). A dedicated ADR can be opened at that point to evaluate a xUnit -> TUnit migration based on concrete data ### Option 4: Purchase an Xceed commercial license [Section titled “Option 4: Purchase an Xceed commercial license”](#option-4-purchase-an-xceed-commercial-license) * **Advantage**: no migration * **Disadvantage**: recurring cost, dependency on a third-party vendor for a test library, risk of further price increases ## Justification [Section titled “Justification”](#justification) | Criterion | Shouldly | FA 6.x | TUnit | Xceed License | | ------------------------- | ------------------ | ----------- | ------------ | ----------------- | | Permissive license | Apache-2.0 | MIT (EOL) | Apache-2.0 | Paid | | Migration effort | Medium | None | Very high | None | | Longevity | Active maintenance | End of life | Recent | Vendor dependency | | xUnit compatibility | Full | Full | Incompatible | Full | | GDPR/ISO 27001 compliance | Yes | Risk (EOL) | Yes | Yes | Shouldly offers the best compliance / migration effort / longevity ratio. > **Note**: TUnit presents real advantages in performance and modernity, but the risk associated with its youth (v1.x, limited ecosystem) does not justify coupling the license problem (urgent) to a framework change (strategic). Migration to TUnit can be re-evaluated independently via a future ADR. ## Assertion mapping [Section titled “Assertion mapping”](#assertion-mapping) | FluentAssertions | Shouldly | | ------------------------------------- | ----------------------------------------------- | | `x.Should().Be(42)` | `x.ShouldBe(42)` | | `x.Should().NotBeNull()` | `x.ShouldNotBeNull()` | | `x.Should().BeTrue()` | `x.ShouldBeTrue()` | | `x.Should().BeFalse()` | `x.ShouldBeFalse()` | | `x.Should().BeNull()` | `x.ShouldBeNull()` | | `list.Should().HaveCount(3)` | `list.Count.ShouldBe(3)` | | `list.Should().BeEmpty()` | `list.ShouldBeEmpty()` | | `list.Should().Contain(item)` | `list.ShouldContain(item)` | | `list.Should().BeEquivalentTo(other)` | `list.ShouldBe(other, ignoreOrder: true)` | | `list.Should().BeInAscendingOrder()` | `list.ShouldBeInOrder(SortDirection.Ascending)` | | `x.Should().BeGreaterThan(0)` | `x.ShouldBeGreaterThan(0)` | | `act.Should().Throw()` | `Should.Throw(() => act())` | | `await act.Should().ThrowAsync()` | `await Should.ThrowAsync(() => act())` | | `.Because("reason")` | `customMessage: "reason"` | ## Consequences [Section titled “Consequences”](#consequences) ### Positive [Section titled “Positive”](#positive) * License compliance restored (Apache-2.0) * ISO 27001 audit risk eliminated * Actively maintained library ### Negative [Section titled “Negative”](#negative) * One-time migration effort on all `*.Tests` projects * Team training on Shouldly syntax (low learning curve) * Documentation update (`docs/testing/assertions.md`, `CLAUDE.md`) ## Execution plan [Section titled “Execution plan”](#execution-plan) 1. Add `Shouldly` to `Directory.Packages.props` (granit-dotnet + consuming applications) 2. Replace assertions in each `*.Tests` project 3. Remove `FluentAssertions` from `Directory.Packages.props` 4. Update `THIRD-PARTY-NOTICES.md` 5. Update `docs/testing/assertions.md` 6. Update `CLAUDE.md` (Tests section) 7. Validate: `dotnet test` passes without failures # ADR-015: Sep — High-Performance CSV Parsing > Selection of Sep for SIMD-vectorized zero-allocation CSV parsing in the DataExchange pipeline > **Date:** 2026-03-01 **Authors:** Jean-Francois Meyers **Scope:** granit-dotnet (Granit.DataExchange.Csv) ## Context [Section titled “Context”](#context) The `Granit.DataExchange.Csv` module requires a CSV parser capable of processing files with 100,000+ rows in streaming, without loading the entire file into memory. Use cases include: patient data import, roundtrip reimport, initial reference data loading. The library must support: * **Streaming**: native `IAsyncEnumerable` for the DataExchange pipeline * **Performance**: large files without degradation * **Encodings**: UTF-8, UTF-8 BOM, Latin-1, Windows-1252 * **RFC 4180**: quoted fields, configurable separators * **License**: compatible with commercial use without recurring costs * **Target**: explicit .NET 10 ## Decision [Section titled “Decision”](#decision) **Sep** (nietras) for CSV parsing in `Granit.DataExchange.Csv`. ## Alternatives considered [Section titled “Alternatives considered”](#alternatives-considered) ### Option 1: Sep (selected) [Section titled “Option 1: Sep (selected)”](#option-1-sep-selected) * **License**: MIT * **Advantage**: zero-allocation after warmup, SIMD vectorization (AVX-512, SSE, ARM NEON), explicit net10.0 target, native `IAsyncEnumerable` (.NET 9+), `Span` / `ISpanParsable`, 9-35x faster than CsvHelper, AOT-compatible * **Maturity**: active releases in 2025 (0.9.0 to 0.12.2), extensive tests * **Disadvantage**: lower-level API (Span-oriented), 0.x version number (conservative versioning by the author, not a sign of instability) ### Option 2: CsvHelper [Section titled “Option 2: CsvHelper”](#option-2-csvhelper) * **License**: MS-PL / Apache-2.0 * **Advantage**: de facto standard (508M NuGet downloads), high-level API (`GetRecordsAsync()`, `ClassMap`, `TypeConverter`), native `IAsyncEnumerable`, excellent documentation, error callbacks (`BadDataFound`) * **Disadvantage**: one `string` allocation per column (significant at 100K+ rows), no SIMD vectorization, no explicit net10.0 target (via netstandard2.0), 9-35x slower than Sep ### Option 3: Sylvan.Data.Csv [Section titled “Option 3: Sylvan.Data.Csv”](#option-3-sylvandatacsv) * **License**: MIT * **Advantage**: 2-3x faster than CsvHelper, familiar `DbDataReader` API, auto-detection of delimiter, Lax mode for malformed data * **Disadvantage**: no explicit net10.0 target, no SIMD, no `IAsyncEnumerable` (only `ReadAsync()`), intermediate performance without decisive advantage over Sep or CsvHelper ### Option 4: RecordParser [Section titled “Option 4: RecordParser”](#option-4-recordparser) * **License**: MIT * **Advantage**: near-zero allocation via expression trees, `Span` * **Disadvantage**: last release November 2023 (18+ months), 116K downloads, no .NET 8/9/10 target, no `IAsyncEnumerable`, minimal documentation, stagnant maintenance ## Justification [Section titled “Justification”](#justification) | Criterion | Sep | CsvHelper | Sylvan.Data.Csv | RecordParser | | ------------------------ | ----------------- | ---------------- | --------------- | ------------ | | License | MIT | MS-PL/Apache-2.0 | MIT | MIT | | Performance vs CsvHelper | **9-35x** | 1x (baseline) | 2-3x | \~2x | | Zero-allocation | **Yes** | No | Low-alloc | Near-zero | | SIMD (AVX-512/NEON) | **Yes** | No | No | No | | Target net10.0 | **Yes** | No (netstandard) | No (net6.0) | No | | IAsyncEnumerable | **Yes** (.NET 9+) | Yes | No | No | | Span/Memory API | **Yes** | No | Partial | Yes | | NuGet downloads | \~1.4M | \~508M | \~3.1M | \~116K | | Active maintenance | **Yes** (2025) | Yes | Yes | No (2023) | | AOT/Trimming | **Yes** | Partial | Partial | Unknown | | API ergonomics | Medium | Excellent | Good | Low | The decisive criterion is **streaming performance** for large files (100K+ rows). Sep is 9-35x faster than CsvHelper thanks to SIMD vectorization and zero allocations. The lower-level API is not a disadvantage as it is encapsulated behind the `IFileParser` interface — consumers never see the Sep API directly. ## Consequences [Section titled “Consequences”](#consequences) ### Positive [Section titled “Positive”](#positive) * Fastest CSV parsing in the .NET ecosystem (SIMD vectorized) * Zero-allocation: no GC pressure on large imports * Explicit net10.0 target: runtime optimizations leveraged * Native `IAsyncEnumerable`: natural integration with the DataExchange pipeline * MIT: no cost, compatible with commercial use * AOT-compatible: no runtime reflection ### Negative [Section titled “Negative”](#negative) * Span-oriented API more verbose than CsvHelper for internal code * 0.x version (though stable and actively maintained) * Smaller community than CsvHelper (1.4M vs 508M downloads) * No native `ClassMap` — mapping is done by `IDataMapper` (by design) * No built-in error callbacks (`BadDataFound`) — handled via `try/catch` in the `SepCsvFileParser` implementation # ADR-016: Sylvan.Data.Excel — Streaming Excel File Reading > Selection of Sylvan.Data.Excel for zero-dependency streaming Excel file reading in the DataExchange pipeline > **Date:** 2026-03-01 **Authors:** Jean-Francois Meyers **Scope:** granit-dotnet (Granit.DataExchange.Excel) ## Context [Section titled “Context”](#context) The `Granit.DataExchange.Excel` module requires an Excel parser capable of reading `.xlsx`, `.xlsb` and `.xls` files in streaming, without loading the entire workbook into memory (DOM model). Use cases include: patient data import, roundtrip reimport, initial loading from legacy `.xls` files. The framework already uses **ClosedXML** for Excel **generation** (`Granit.DocumentGeneration.Excel`). For **reading** (import), ClosedXML is unsuitable as it loads the complete DOM into memory (hundreds of MB for 100K+ rows). The library must support: * **Streaming**: forward-only reading without DOM loading * **Formats**: `.xlsx`, `.xlsb`, `.xls` (legacy files) * **Performance**: 100K+ rows with minimal memory footprint * **Async**: non-blocking support for the DataExchange pipeline * **License**: compatible with commercial use without recurring costs * **Dependencies**: minimal (avoid conflicts with ClosedXML) ## Decision [Section titled “Decision”](#decision) **Sylvan.Data.Excel** for Excel file reading in `Granit.DataExchange.Excel`. > ClosedXML remains for **generation** (`Granit.DocumentGeneration.Excel`). ## Alternatives considered [Section titled “Alternatives considered”](#alternatives-considered) ### Option 1: Sylvan.Data.Excel (selected) [Section titled “Option 1: Sylvan.Data.Excel (selected)”](#option-1-sylvandataexcel-selected) * **License**: MIT * **Advantage**: zero transitive dependencies (pure managed), `DbDataReader` forward-only streaming, `.xlsx`/`.xlsb`/`.xls` support, lowest memory footprint in the ecosystem, native async (`CreateAsync`, `ReadAsync`) * **Maturity**: Sylvan ecosystem (Csv at 3.1M downloads), version 0.5.2 * **Disadvantage**: smaller community (867K downloads), no native `IAsyncEnumerable` (wrapping required) ### Option 2: ClosedXML (already used for generation) [Section titled “Option 2: ClosedXML (already used for generation)”](#option-2-closedxml-already-used-for-generation) * **License**: MIT * **Advantage**: rich API, already in the dependency graph, same library for reading and writing * **Disadvantage**: **DOM model** — loads the entire workbook into memory. For 100K rows, consumption of hundreds of MB (each `XLCell` has its own `XLStyle`). Unsuitable for large file imports. ### Option 3: MiniExcel [Section titled “Option 3: MiniExcel”](#option-3-miniexcel) * **License**: Apache-2.0 * **Advantage**: very simple API (`Query()` in one line), SAX-like streaming (\~17 MB for 1M rows), native `IAsyncEnumerable` (v2 preview) * **Disadvantage**: transitive dependency on `DocumentFormat.OpenXml` (version conflict risk with ClosedXML which depends on the same package), no `.xls` or `.xlsb` support, typed access via dynamic/Dictionary (possible runtime errors) ### Option 4: ExcelDataReader [Section titled “Option 4: ExcelDataReader”](#option-4-exceldatareader) * **License**: MIT * **Advantage**: most popular (92M downloads), `IDataReader` forward-only, `.xls`/`.xlsx`/`.xlsb` support, battle-tested * **Disadvantage**: **no async support** (no `async`, no `ReadAsync`, no `IAsyncEnumerable`), targets only netstandard2.0 (no modern .NET optimizations), basic typed accessors ### Option 5: Open XML SDK (Microsoft) [Section titled “Option 5: Open XML SDK (Microsoft)”](#option-5-open-xml-sdk-microsoft) * **License**: MIT * **Advantage**: official SDK, SAX mode (`OpenXmlReader`) for ultimate streaming * **Disadvantage**: **very low-level** API — direct XML element manipulation, manual shared string table management, cell reference interpretation, style index management. Hundreds of lines for what other libraries accomplish in one line. ## Justification [Section titled “Justification”](#justification) | Criterion | Sylvan.Data.Excel | ClosedXML | MiniExcel | ExcelDataReader | Open XML SDK | | ---------------- | ----------------- | ----------------- | ------------------ | ---------------- | ------------ | | License | MIT | MIT | Apache-2.0 | MIT | MIT | | Reading model | **Forward-only** | DOM (all in RAM) | SAX streaming | Forward-only | DOM or SAX | | Memory 100K rows | **Very low** | Hundreds MB | \~17 MB | Low-medium | SAX: low | | Formats | .xlsx/.xlsb/.xls | .xlsx | .xlsx/.csv | .xlsx/.xlsb/.xls | .xlsx/.xlsb | | Async | **Yes** | No | Yes | **No** | No | | Transitive deps | **Zero** | OpenXml | OpenXml | None | N/A | | API | DbDataReader | Rich object model | dynamic/Dictionary | IDataReader | XML nodes | | NuGet downloads | \~867K | \~45M | \~10.1M | \~92M | \~250M+ | The decisive criterion is the combination of **zero transitive dependencies** + **forward-only streaming** + **async support** + **legacy .xls support**. Sylvan.Data.Excel is the only one to check all four boxes. The **zero dependencies** point is critical: `Granit.DocumentGeneration.Excel` already pulls `ClosedXML` -> `DocumentFormat.OpenXml`. Adding MiniExcel would bring a second transitive dependency on `DocumentFormat.OpenXml` with a version conflict risk. Sylvan.Data.Excel avoids this problem entirely. ## Consequences [Section titled “Consequences”](#consequences) ### Positive [Section titled “Positive”](#positive) * Lowest memory footprint for Excel file reading in .NET * Zero transitive dependencies (no conflict with ClosedXML/OpenXml) * Support for all 3 common formats: `.xlsx`, `.xlsb`, `.xls` (legacy) * Familiar and strongly-typed `DbDataReader` API * Native async (`CreateAsync`, `ReadAsync`) * MIT: no cost, compatible with commercial use ### Negative [Section titled “Negative”](#negative) * Smaller community than ExcelDataReader or MiniExcel * Version 0.5.x (stable Sylvan ecosystem but conservative versioning) * No native `IAsyncEnumerable` — requires a wrapper in `SylvanExcelFileParser` (trivial: `while ReadAsync yield return` loop) * No support for password-protected files (rare case for import) # Frontend Architecture > Architecture overview of granit-front — monorepo structure, dependency layers, source-direct pattern, and .NET alignment. ## Overview [Section titled “Overview”](#overview) `granit-front` is a pnpm monorepo containing the `@granit/*` packages — the JavaScript/TypeScript counterpart of the .NET framework `granit-dotnet`. Both frameworks expose symmetric contracts: TypeScript types in `@granit/querying`, `@granit/data-exchange`, `@granit/workflow`, etc. are the direct mirror of C# types in `Granit.Querying`, `Granit.DataExchange`, `Granit.Workflow`, etc. ## Core principles [Section titled “Core principles”](#core-principles) | Principle | Description | | --------------------- | ----------------------------------------------------------------------------------------------- | | **Source-direct** | No build step — packages export `.ts` files consumed directly by Vite | | **Headless** | Packages expose only hooks, types, and providers — UI components live in consuming applications | | **App-agnostic** | No application-specific logic — only reusable abstractions | | **Peer dependencies** | External dependencies are declared as `peerDependencies`, never `dependencies` | ## Technical stack [Section titled “Technical stack”](#technical-stack) * **TypeScript** 5 (strict) / **React** 19 * **Vitest** 4 / **ESLint** 10 / **Prettier** 3 * **pnpm** workspace / **Node** 24+ * **Conventional Commits** via commitlint + husky ## Dependency graph [Section titled “Dependency graph”](#dependency-graph) ``` graph TD subgraph "Foundation layer" logger["@granit/logger"] utils["@granit/utils"] storage["@granit/storage"] cookies["@granit/cookies"] end subgraph "Infrastructure layer" api["@granit/api-client"] react-authn["@granit/react-authentication"] react-authz["@granit/react-authorization"] localization["@granit/localization"] logger-otlp["@granit/logger-otlp"] cookies-klaro["@granit/cookies-klaro"] tracing["@granit/tracing"] error-boundary["@granit/error-boundary"] end subgraph "Business layer" querying["@granit/querying"] data-exchange["@granit/data-exchange"] workflow["@granit/workflow"] timeline["@granit/timeline"] notifications["@granit/notifications"] end react-authn --> api localization --> storage logger-otlp --> logger cookies-klaro --> cookies error-boundary --> logger querying --> utils data-exchange --> utils timeline --> querying notifications --> querying utils -.-> clsx["clsx"] utils -.-> tw["tailwind-merge"] utils -.-> datefns["date-fns"] api -.-> axios["axios"] react-authn -.-> keycloak["keycloak-js"] querying -.-> tanstack["@tanstack/react-query"] data-exchange -.-> tanstack notifications -.-> signalr["@microsoft/signalr"] tracing -.-> otel["@opentelemetry/*"] logger-otlp -.-> otel cookies-klaro -.-> klaro["klaro"] localization -.-> i18next["i18next"] style logger fill:#e8f5e9 style utils fill:#e8f5e9 style storage fill:#e8f5e9 style cookies fill:#e8f5e9 style api fill:#e3f2fd style react-authn fill:#e3f2fd style react-authz fill:#e3f2fd style localization fill:#e3f2fd style logger-otlp fill:#e3f2fd style cookies-klaro fill:#e3f2fd style tracing fill:#e3f2fd style error-boundary fill:#e3f2fd style querying fill:#fff3e0 style data-exchange fill:#fff3e0 style workflow fill:#fff3e0 style timeline fill:#fff3e0 style notifications fill:#fff3e0 ``` **Legend:** * **Green** (foundation) — packages with no internal `@granit` dependency * **Blue** (infrastructure) — packages depending on a foundation package * **Orange** (business) — packages implementing business functionality ## Vertical slice pattern [Section titled “Vertical slice pattern”](#vertical-slice-pattern) Each business package follows a vertical slice structure: ```text packages/@granit//src/ types/ # TypeScript contracts (mirror of .NET types) api/ # HTTP call functions (axios) hooks/ # React hooks (business logic) providers/ # React context providers utils/ # Package-internal utilities __tests__/ # Unit tests (Vitest) index.ts # Single entry point (public re-exports) ``` This reflects the data flow: ```text types/ → api/ → hooks/ → providers/ ``` 1. **types/** defines the data contract (aligned with .NET backend) 2. **api/** encapsulates HTTP calls via `axios` 3. **hooks/** orchestrates data fetching (often via `@tanstack/react-query`) and exposes business logic 4. **providers/** supplies React context for consuming components Foundation packages (`logger`, `utils`, `storage`, `cookies`) are simpler and do not necessarily have all these layers. ## Source resolution flow [Section titled “Source resolution flow”](#source-resolution-flow) ```text import { useQueryEndpoint } from '@granit/querying' → Vite alias → packages/@granit/querying/src/index.ts → TypeScript source → transpiled on the fly by Vite → no dist/, no intermediate build ``` This **source-direct** approach provides: * **Instant HMR** on framework code changes * **No build watch** to maintain for the monorepo * **Direct source maps** to the original code * **Frictionless refactoring** across framework and application For Docker/CI builds, packages are compiled with `tsup` (ESM + `.d.ts`) and published to the [GitHub Packages npm registry](/operations/frontend-npm-registry/). ## Alignment with granit-dotnet [Section titled “Alignment with granit-dotnet”](#alignment-with-granit-dotnet) | granit-dotnet (.NET) | granit-front (TypeScript) | | ---------------------------- | ---------------------------------------- | | `Granit.Querying` | `@granit/querying` | | `Granit.DataExchange.Export` | `@granit/data-exchange` (export) | | `Granit.DataExchange.Import` | `@granit/data-exchange` (import) | | `Granit.Workflow` | `@granit/workflow` | | `Granit.Notifications` | `@granit/notifications` | | `Granit.Timeline` | `@granit/timeline` | | Controllers .NET | Endpoints consumed by `api/` | | `ProblemDetails` | `ProblemDetails` in `@granit/api-client` | | `PagedResult` | `PagedResult` in `@granit/api-client` | TypeScript types in `types/` of each package are the faithful mirror of C# DTOs on the backend. Any change to a .NET contract must be propagated to the corresponding TypeScript type, and vice versa. ## See also [Section titled “See also”](#see-also) * [Frontend SDK Reference](/reference/frontend/) — package documentation * [Backend Architecture](/architecture/) — .NET architecture overview * [Frontend Patterns](/architecture/patterns-frontend/) — design patterns * [Frontend ADRs](/architecture/adr-frontend/) — architecture decision records # Pattern Library > 51 design patterns and their implementation in Granit A catalogue of design patterns used in the Granit framework, organized by category. Each pattern documents the general concept, how it is implemented in Granit, and references to the actual source files where the pattern is applied. ## Architecture patterns [Section titled “Architecture patterns”](#architecture-patterns) | Pattern | Description | | --------------------------------------------------- | ------------------------------------------------------- | | [Module System](./module-system/) | Topological loading with `[DependsOn]` | | [Hexagonal Architecture](./hexagonal-architecture/) | Ports and Adapters for infrastructure decoupling | | [Layered Architecture](./layered-architecture/) | Domain / Application / Infrastructure separation | | [Middleware Pipeline](./middleware-pipeline/) | Dual ASP.NET Core + Wolverine pipeline | | [Event-Driven](./event-driven/) | IDomainEvent (local) + IIntegrationEvent (durable) | | [REPR](./repr/) | Minimal API Request-Endpoint-Response | | [CQRS](./cqrs/) | IReader / IWriter separation, ArchUnitNET enforcement | | [Anti-Corruption Layer](./anti-corruption-layer/) | Isolation of Keycloak, S3, Brevo, FCM via internal DTOs | ## Cloud and SaaS patterns [Section titled “Cloud and SaaS patterns”](#cloud-and-saas-patterns) | Pattern | Description | | ----------------------------------------------------- | ---------------------------------------------------------- | | [Multi-Tenancy](./multi-tenancy/) | 3 isolation strategies, soft dependency, async propagation | | [Feature Flags](./feature-flags/) | Multi-level resolution Tenant to Plan to Default | | [Transactional Outbox](./transactional-outbox/) | Atomic event publishing via Wolverine Outbox | | [Idempotency](./idempotency/) | Stripe-style HTTP idempotency with state machine | | [Pre-Signed URL](./pre-signed-url/) | Direct-to-cloud S3 upload/download | | [Sidecar / Behavior](./sidecar-behavior/) | Context propagation via Wolverine Behaviors | | [Circuit Breaker and Retry](./circuit-breaker-retry/) | Standard resilience + Wolverine RetryWithCooldown | | [Cache-Aside](./cache-aside/) | Double-check locking + HybridCache L1/L2 | | [Rate Limiting](./rate-limiting/) | Per-tenant rate limiting with dynamic quotas | | [Saga / Process Manager](./saga-process-manager/) | GDPR export, import/export orchestrators | | [Fan-Out](./fan-out/) | Wolverine cascade for notifications and webhooks | | [Claim Check](./claim-check/) | Soft dependency IClaimCheckStore for large payloads | | [Bulkhead Isolation](./bulkhead-isolation/) | Queue isolation, parallelism, tenant quotas | ## GoF behavioral patterns [Section titled “GoF behavioral patterns”](#gof-behavioral-patterns) | Pattern | Description | | ----------------------------------------------------- | -------------------------------------------------------------------- | | [Strategy](./strategy/) | TenantIsolationStrategy, IBlobKeyStrategy, IStringEncryptionProvider | | [Chain of Responsibility](./chain-of-responsibility/) | TenantResolverPipeline, blob validation | | [Command](./command/) | SendWebhookCommand, RunMigrationBatchCommand | | [Template Method](./template-method/) | GranitModule lifecycle, GranitValidator | | [State Machine](./state-machine/) | IdempotencyState, BlobStatus | | [Observer / Event](./observer-event/) | Wolverine implicit event subscription | | [Mediator](./mediator/) | Wolverine message bus | | [Null Object](./null-object/) | NullTenantContext, NullCacheValueEncryptor | ## GoF creational patterns [Section titled “GoF creational patterns”](#gof-creational-patterns) | Pattern | Description | | ----------------------------------- | ------------------------------------------------- | | [Factory Method](./factory-method/) | VaultClientFactory, DbContext tenant factories | | [Singleton](./singleton/) | AsyncLocal singletons, NullTenantContext.Instance | | [Builder](./builder/) | Fluent `AddGranit*()` extensions | ## GoF structural patterns [Section titled “GoF structural patterns”](#gof-structural-patterns) | Pattern | Description | | ------------------------- | -------------------------------------------------------- | | [Adapter](./adapter/) | TypedKeyCacheServiceAdapter, S3BlobClient | | [Decorator](./decorator/) | DistributedCacheService, CachedLocalizationOverrideStore | | [Proxy](./proxy/) | FilterProxy for EF Core, Interceptors | | [Facade](./facade/) | DefaultBlobStorage, GranitExceptionHandler | | [Composite](./composite/) | Auditable entity hierarchy | ## Data patterns [Section titled “Data patterns”](#data-patterns) | Pattern | Description | | ----------------------------------- | ----------------------------------------------------- | | [Repository](./repository/) | Store interfaces + EF Core / InMemory implementations | | [Soft Delete](./soft-delete/) | ISoftDeletable + SoftDeleteInterceptor (GDPR) | | [Data Filtering](./data-filtering/) | IDataFilter with ImmutableDictionary AsyncLocal | | [Unit of Work](./unit-of-work/) | Implicit DbContext + interceptor chain | | [Specification](./specification/) | QueryDefinition whitelist-first, expression trees | ## Concurrency patterns [Section titled “Concurrency patterns”](#concurrency-patterns) | Pattern | Description | | --------------------------------------------------- | ----------------------------------------- | | [Scope / Context Manager](./scope-context-manager/) | `using` pattern for context restoration | | [Copy-on-Write](./copy-on-write/) | ImmutableDictionary for thread-safe state | | [Double-Check Locking](./double-check-locking/) | Anti-stampede on cache miss | ## .NET idiom patterns [Section titled “.NET idiom patterns”](#net-idiom-patterns) | Pattern | Description | | --------------------------------------- | ------------------------------------------ | | [Expression Trees](./expression-trees/) | Dynamic EF Core query filter construction | | [Marker Interface](./marker-interface/) | ISoftDeletable, IMultiTenant, IDomainEvent | | [Options Pattern](./options-pattern/) | 93 Options classes, ValidateOnStart | ## Security patterns [Section titled “Security patterns”](#security-patterns) | Pattern | Description | | ------------------------------------------------- | ----------------------------------------- | | [Claims-Based Identity](./claims-based-identity/) | JWT Keycloak + dynamic RBAC | | [Guard Clause](./guard-clause/) | Systematic fail-fast, semantic exceptions | ## Granit-specific variants [Section titled “Granit-specific variants”](#granit-specific-variants) | Pattern | Description | | ------------------------------------- | ----------------------------------- | | [Granit Variants](./granit-variants/) | 10 hybrid patterns unique to Granit | # Frontend Pattern Library > 8 design patterns and their implementation in the Granit TypeScript/React SDK A catalogue of design patterns used in the Granit frontend SDK, organized by category. Each pattern documents the general concept, how it is implemented in the `@granit/*` packages, and concrete code examples from the SDK. ## Creation patterns [Section titled “Creation patterns”](#creation-patterns) | Pattern | Description | | --------------------------------------- | --------------------------------------------------------- | | [Factory](./factory/) | Hide instance creation complexity behind simple functions | | [Module Singleton](./module-singleton/) | Cross-package state sharing via ES module cache | ## Structural patterns [Section titled “Structural patterns”](#structural-patterns) | Pattern | Description | | --------------------- | ------------------------------------------------------- | | [Adapter](./adapter/) | Convert 3rd-party APIs into React-compatible interfaces | ## Behavioral patterns [Section titled “Behavioral patterns”](#behavioral-patterns) | Pattern | Description | | ----------------------------- | ----------------------------------------------------- | | [Interceptor](./interceptor/) | Transparent HTTP request/response pipeline processing | | [Strategy](./strategy/) | Pluggable implementations behind a common interface | | [Observer](./observer/) | Event notification without direct coupling | ## React patterns [Section titled “React patterns”](#react-patterns) | Pattern | Description | | --------------------------------------- | ----------------------------------------------------- | | [Provider](./provider/) | Context-based dependency injection with typed hooks | | [Hook Composition](./hook-composition/) | Layer framework + application logic via hook wrapping | # Adapter > Convert third-party APIs into React-compatible interfaces with typed state ## Definition [Section titled “Definition”](#definition) The Adapter pattern converts a third-party library’s API into a React-compatible interface. It isolates application code from library details — transforming imperative callbacks and mutable state into reactive hooks with typed state. ## Diagram [Section titled “Diagram”](#diagram) ``` graph LR Keycloak["keycloak-js\n(imperative)"] --> Adapter["useKeycloakInit\n(adapter)"] Adapter --> React["React state\n(reactive)"] ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) | Source | Adapter | Target | | ------------------------- | ------------------------------------ | ------------------------------------------------- | | `keycloak-js` API | `useKeycloakInit` hook | Reactive `{ authenticated, loading, user }` state | | `klaro/dist/klaro-no-css` | `createKlaroCookieConsentProvider()` | `CookieConsentProvider` interface | ### Keycloak adaptation [Section titled “Keycloak adaptation”](#keycloak-adaptation) | keycloak-js native | Adapted interface | | --------------------------------------- | ---------------------------------------- | | `keycloak.init({ onLoad, pkceMethod })` | Single `useEffect` with init guard | | `keycloak.loadUserInfo()` → Promise | `user: KeycloakUserInfo \| null` (state) | | `keycloak.onTokenExpired = callback` | Automatic renewal every 60s | | `keycloak.token` (mutable string) | Transparent wiring to `setTokenGetter()` | | `keycloak.authenticated` (mutable bool) | `authenticated: boolean` (reactive) | ## Rationale [Section titled “Rationale”](#rationale) Keycloak-js uses callbacks, promises, and mutable properties — a paradigm mismatch with React’s declarative model. The adapter bridges this gap so that the rest of the application works with standard React state. ## Usage example [Section titled “Usage example”](#usage-example) ```tsx import { useKeycloakInit } from '@granit/react-authentication'; function AuthProvider({ children }: { children: React.ReactNode }) { const { authenticated, loading, user, login, logout } = useKeycloakInit({ url: import.meta.env.VITE_KEYCLOAK_URL, realm: import.meta.env.VITE_KEYCLOAK_REALM, clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID, }); if (loading) return ; if (!authenticated) { login(); return null; } return ( {children} ); } ``` ## Further reading [Section titled “Further reading”](#further-reading) * [Adapter — refactoring.guru](https://refactoring.guru/design-patterns/adapter) # Factory > Encapsulate complex object creation behind simple functions across all @granit packages ## Definition [Section titled “Definition”](#definition) The Factory pattern encapsulates complex object creation logic behind simple functions. Callers provide minimal configuration and receive ready-to-use instances without knowing initialization details. This is the dominant pattern in granit-front — every package exposes at least one factory. ## Diagram [Section titled “Diagram”](#diagram) ``` graph LR Config["Config object"] --> Factory["createXxx()"] Factory --> Instance["Ready-to-use instance"] ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) | Factory | Package | Creates | | ------------------------------------------- | ------------------------------- | ------------------------------------------ | | `createLogger(prefix, options?)` | `@granit/logger` | Logger with configured transports | | `createApiClient(config)` | `@granit/api-client` | Axios instance with Bearer interceptor | | `createAuthContext()` | `@granit/react-authentication` | Generic, type-safe auth context + hook | | `createLocalization(config?)` | `@granit/localization` | Isolated i18next instance | | `createReactLocalization(config?)` | `@granit/react-localization` | i18next with `initReactI18next` plugin | | `createSignalRTransport(config)` | `@granit/notifications-signalr` | SignalR notification transport | | `createSseTransport(config)` | `@granit/notifications-sse` | SSE notification transport | | `createKlaroCookieConsentProvider(options)` | `@granit/cookies-klaro` | Klaro CMP adapter | | `createStorage(key, options?)` | `@granit/storage` | Typed localStorage/sessionStorage accessor | | `createMockProvider()` | `@granit/react-authentication` | Test provider using same context | ## Rationale [Section titled “Rationale”](#rationale) Factory functions keep the public API surface minimal while hiding initialization complexity (transport wiring, plugin injection, default configuration). They also enable tree-shaking — unused factories are eliminated at build time. ## Usage example [Section titled “Usage example”](#usage-example) ```ts import { createLogger } from '@granit/logger'; import { createApiClient } from '@granit/api-client'; import { createAuthContext } from '@granit/react-authentication'; import type { BaseAuthContextType } from '@granit/authentication'; // Logger with console transport const logger = createLogger('app'); // HTTP client with automatic Bearer injection const api = createApiClient({ baseURL: import.meta.env.VITE_API_URL }); // Typed auth context for the application interface AuthContextType extends BaseAuthContextType { register: () => void; } export const { AuthContext, useAuth } = createAuthContext(); ``` ## Further reading [Section titled “Further reading”](#further-reading) * [Factory Method — refactoring.guru](https://refactoring.guru/design-patterns/factory-method) # Hook Composition > Layer framework hooks with application logic via wrapper components ## Definition [Section titled “Definition”](#definition) Hook Composition assembles low-level framework hooks with application logic in a wrapper component. The framework hook provides the foundation (authentication, state, lifecycle); the application adds its business logic on top. ## Diagram [Section titled “Diagram”](#diagram) ``` graph TD Framework["useKeycloakInit()\n(framework layer)"] --> Composition["AuthProvider\n(composition point)"] AppLogic["Application logic\n(Capacitor, locale, roles)"] --> Composition Composition --> Context["AuthContext.Provider\n(consumed by app)"] ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) | Framework hook | Application layer adds | Composition point | | ------------------------ | ------------------------------------------- | ------------------------------ | | `useKeycloakInit` | Capacitor browser, locale, custom redirects | `AuthProvider` in consumer app | | `useNotificationContext` | Custom toast rendering, sound effects | Notification wrapper component | | `useQueryEndpoint` | Domain-specific column transforms | Data table component | ## Rationale [Section titled “Rationale”](#rationale) Framework hooks must remain generic — no Capacitor, Electron, or app-specific code. Hook Composition lets applications extend framework behavior without modifying the framework. The boundary is clear: framework hooks handle the universal lifecycle, application wrappers add the specific logic. ## Usage example [Section titled “Usage example”](#usage-example) ### Minimal composition (web-only) [Section titled “Minimal composition (web-only)”](#minimal-composition-web-only) ```tsx function AuthProvider({ children }: { children: React.ReactNode }) { const auth = useKeycloakInit({ url: import.meta.env.VITE_KEYCLOAK_URL, realm: import.meta.env.VITE_KEYCLOAK_REALM, clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID, }); if (auth.loading) return ; return ( {children} ); } ``` ### Advanced composition (Capacitor + custom login) [Section titled “Advanced composition (Capacitor + custom login)”](#advanced-composition-capacitor--custom-login) ```tsx function AuthProvider({ children }: { children: React.ReactNode }) { // Framework layer const { keycloak, keycloakRef, authenticated, loading, user, login: hookLogin, logout: hookLogout, } = useKeycloakInit(config); // Application layer — platform-specific logic const login = useCallback(async () => { if (isNative) { const url = keycloakRef.current?.createLoginUrl({ redirectUri }); await Browser.open({ url }); // Capacitor: system browser } else { hookLogin({ locale }); } }, [isNative, keycloakRef, hookLogin]); // Stabilization for context consumers const value = useMemo( () => ({ keycloak, authenticated, loading, user, login, logout }), [keycloak, authenticated, loading, user, login, logout], ); return {children}; } ``` The framework `useKeycloakInit` handles Keycloak init, PKCE, token refresh, and React state. The application wrapper adds Capacitor-specific login flow and `useMemo` stabilization — without any change to the framework hook. ## Further reading [Section titled “Further reading”](#further-reading) * [Custom Hooks — react.dev](https://react.dev/learn/reusing-logic-with-custom-hooks) # Interceptor > Transparent HTTP request/response pipeline processing for token injection and error handling ## Definition [Section titled “Definition”](#definition) The Interceptor pattern inserts transparent processing into a request/response pipeline. Calling code is unaware of the interceptor — it sends a request and receives a response as if nothing intervened. ## Diagram [Section titled “Diagram”](#diagram) ``` graph LR App["Application code"] --> Interceptor["Request interceptor\n(Bearer injection)"] Interceptor --> Server["HTTP server"] Server --> RespInterceptor["Response interceptor\n(error handling)"] RespInterceptor --> App ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) | Interceptor | Package | Purpose | | ------------------------- | --------------------- | ------------------------------------------------ | | Request — Bearer token | `@granit/api-client` | Automatic `Authorization` header injection | | Request — Idempotency key | `@granit/idempotency` | Automatic `Idempotency-Key` header on POST/PATCH | | Response — error handling | Application | 401/403 redirect, error normalization | The framework handles token injection. Error handling (401, 403, network failures) is the application’s responsibility — this separation is by design. ## Rationale [Section titled “Rationale”](#rationale) Bearer token injection must happen on every authenticated request. Making this an interceptor means no API call site needs to handle authentication manually. The pipeline is extensible — applications add their own interceptors for error handling without modifying framework code. ## Usage example [Section titled “Usage example”](#usage-example) ```ts import { createApiClient } from '@granit/api-client'; import axios from 'axios'; const api = createApiClient({ baseURL: import.meta.env.VITE_API_URL }); // Framework interceptor already in place — calls are transparent const { data } = await api.get('/patients'); // Application adds its own response interceptor api.interceptors.response.use( (response) => response, async (error: unknown) => { if (axios.isAxiosError(error) && error.response?.status === 401) { window.location.href = '/login'; } throw error; }, ); ``` ## Further reading [Section titled “Further reading”](#further-reading) * [Interceptor — Wikipedia](https://en.wikipedia.org/wiki/Interceptor_pattern) # Module Singleton > Cross-package state sharing via ES module cache for token injection and global log level ## Definition [Section titled “Definition”](#definition) The Module Singleton pattern exploits the ES module cache to maintain a unique global state. A private module variable is shared by all importers — no static class or global registry required. ## Diagram [Section titled “Diagram”](#diagram) ``` graph TD A["@granit/react-authentication"] -->|"setTokenGetter()"| Singleton["_tokenGetter\n(module variable)"] B["@granit/api-client\n(interceptor)"] -->|"reads"| Singleton ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) | Singleton variable | Package | Purpose | | ------------------ | -------------------- | ------------------------------------------------------- | | `_tokenGetter` | `@granit/api-client` | Bearer token getter shared between auth and HTTP client | | Global log level | `@granit/logger` | Shared log level across all logger instances | ## Rationale [Section titled “Rationale”](#rationale) Token management requires coordination between `@granit/react-authentication` (which obtains tokens from Keycloak) and `@granit/api-client` (which injects them into HTTP requests). A module singleton avoids direct package coupling — the auth package calls `setTokenGetter()` once during startup, and the interceptor reads it on every request. ## Usage example [Section titled “Usage example”](#usage-example) ```ts // @granit/api-client — private module variable let _tokenGetter: (() => Promise) | null = null; export function setTokenGetter( getter: () => Promise ): void { _tokenGetter = getter; } // Axios interceptor reads _tokenGetter on every request instance.interceptors.request.use(async (req) => { if (_tokenGetter) { const token = await _tokenGetter(); if (token) req.headers.Authorization = `Bearer ${token}`; } return req; }); ``` `useKeycloakInit` calls `setTokenGetter()` internally — the application never wires this manually. ## Further reading [Section titled “Further reading”](#further-reading) * [Singleton — refactoring.guru](https://refactoring.guru/design-patterns/singleton) # Observer > Event notification for Keycloak lifecycle and notification transport state changes ## Definition [Section titled “Definition”](#definition) The Observer pattern allows a subject to notify subscribers when an event occurs, without direct coupling. Subscribers react to events according to their own logic. ## Diagram [Section titled “Diagram”](#diagram) ``` graph TD Subject["Keycloak (subject)"] -->|"onTokenExpired"| Framework["Framework\n(auto-renew token)"] Subject -->|"onTokenExpired"| App["Application callback\n(log warning)"] Subject -->|"all events"| Generic["Generic observer\n(centralized logging)"] ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) | Subject | Events | Package | | ----------------------- | -------------------------------------------------- | ------------------------------ | | `keycloak-js` | `onReady`, `onAuthSuccess`, `onTokenExpired`, etc. | `@granit/react-authentication` | | `NotificationTransport` | `onNotification`, `onStateChange` | `@granit/notifications` | | `CookieConsentProvider` | `onConsentChange` | `@granit/cookies` | ### Keycloak dual-callback design [Section titled “Keycloak dual-callback design”](#keycloak-dual-callback-design) The hook supports two callback types: * **Specific observers**: `onTokenExpired?`, `onAuthRefreshError?`, `onAuthLogout?` * **Generic observer**: `onEvent?(event, error?)` — receives all events The framework executes default behavior first (e.g. `updateToken(60)` on expiry), then propagates to application callbacks. ## Rationale [Section titled “Rationale”](#rationale) Keycloak emits lifecycle events that require both framework-level behavior (token renewal) and application-level behavior (logging, UI updates). The Observer pattern lets both coexist without coupling. ## Usage example [Section titled “Usage example”](#usage-example) ```ts type KeycloakEvent = | 'onReady' | 'onAuthSuccess' | 'onAuthError' | 'onAuthRefreshSuccess' | 'onAuthRefreshError' | 'onAuthLogout' | 'onTokenExpired'; const auth = useKeycloakInit({ url: import.meta.env.VITE_KEYCLOAK_URL, realm: import.meta.env.VITE_KEYCLOAK_REALM, clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID, // Specific observer onTokenExpired: () => { logger.warn('Token expired — renewing'); }, // Generic observer — centralized logging onEvent: (event, error) => { logger.debug('Keycloak event', { event, error }); }, }); ``` ## Further reading [Section titled “Further reading”](#further-reading) * [Observer — refactoring.guru](https://refactoring.guru/design-patterns/observer) # Provider (React Context) > Context-based dependency injection with typed hooks and generic factory ## Definition [Section titled “Definition”](#definition) The Provider pattern uses React Context to inject shared state into the component tree. A high-level component provides a value; descendant components access it via a hook — no prop drilling. Combined with a generic factory, each application can type its own context. ## Diagram [Section titled “Diagram”](#diagram) ``` graph TD Provider["XxxProvider\n(context value)"] --> Hook["useXxx()\n(typed access)"] Hook --> ComponentA["Component A"] Hook --> ComponentB["Component B"] Hook --> ComponentC["Component C"] ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) Every `@granit/react-*` package follows this pattern: | Provider | Hook | Package | | ----------------------------------- | -------------------------- | ------------------------------ | | `WorkflowProvider` | `useWorkflowConfig()` | `@granit/react-workflow` | | `TimelineProvider` | `useTimelineConfig()` | `@granit/react-timeline` | | `NotificationProvider` | `useNotificationContext()` | `@granit/react-notifications` | | `QueryProvider` | `useQueryConfig()` | `@granit/react-querying` | | `SettingsProvider` | `useSettingsConfig()` | `@granit/react-settings` | | `TemplatingProvider` | `useTemplatingConfig()` | `@granit/templating` | | `ExportProvider` / `ImportProvider` | context hooks | `@granit/react-data-exchange` | | `ErrorContextProvider` | `useErrorContext()` | `@granit/react-error-boundary` | | `CookieConsentProvider` | `useCookieConsent()` | `@granit/react-cookies` | | `TracingProvider` | `useTracer()` | `@granit/react-tracing` | ### Generic factory for authentication [Section titled “Generic factory for authentication”](#generic-factory-for-authentication) ```ts // createAuthContext() creates both the context and the hook export const { AuthContext, useAuth } = createAuthContext(); ``` Each application defines its own `AuthContextType` extending `BaseAuthContextType`. ## Rationale [Section titled “Rationale”](#rationale) React applications need to share configuration (API client, base path) and state (connection status, current user) across deeply nested components. Context avoids prop drilling while keeping the dependency explicit — the hook throws if called outside its Provider. ## Usage example [Section titled “Usage example”](#usage-example) ```tsx // 1. Define typed context interface AuthContextType extends BaseAuthContextType { register: () => void; } export const { AuthContext, useAuth } = createAuthContext(); // 2. Provide value at the top of the tree function AuthProvider({ children }: { children: React.ReactNode }) { const value: AuthContextType = { /* ... */ }; return {children}; } // 3. Consume anywhere below function NavBar() { const { user, logout } = useAuth(); return ; } // 4. Mock for testing / Storybook import { createMockProvider } from '@granit/react-authentication'; const MockAuth = createMockProvider(AuthContext, { keycloak: null, authenticated: true, loading: false, user: { sub: '1', name: 'Test' }, login: () => {}, logout: () => {}, register: () => {}, }); ``` ## Further reading [Section titled “Further reading”](#further-reading) * [React Context — react.dev](https://react.dev/learn/passing-data-deeply-with-context) # Strategy > Pluggable implementations behind a common interface for log transports and notification channels ## Definition [Section titled “Definition”](#definition) The Strategy pattern defines a family of interchangeable algorithms behind a common interface. The context delegates behavior to a strategy object without knowing its implementation. ## Diagram [Section titled “Diagram”](#diagram) ``` classDiagram class LogTransport { <> +send(entry: LogEntry): void +flush?(): Promise~void~ } class ConsoleTransport class OtlpTransport class SentryTransport LogTransport <|.. ConsoleTransport LogTransport <|.. OtlpTransport LogTransport <|.. SentryTransport class NotificationTransport { <> +connect(): Promise~void~ +disconnect(): Promise~void~ +onNotification(cb): unsubscribe } class SignalRTransport class SseTransport NotificationTransport <|.. SignalRTransport NotificationTransport <|.. SseTransport ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) | Strategy interface | Package | Implementations | | ----------------------- | ----------------------- | --------------------------------------------------- | | `LogTransport` | `@granit/logger` | `createConsoleTransport()`, `createOtlpTransport()` | | `NotificationTransport` | `@granit/notifications` | `createSignalRTransport()`, `createSseTransport()` | | `CookieConsentProvider` | `@granit/cookies` | `createKlaroCookieConsentProvider()` | ## Rationale [Section titled “Rationale”](#rationale) Log destinations and real-time notification channels vary by deployment. The Strategy pattern lets applications compose the exact set of transports they need without modifying framework code. A Sentry transport can be added alongside the console transport without either knowing about the other. ## Usage example [Section titled “Usage example”](#usage-example) ```ts import { createLogger, createConsoleTransport } from '@granit/logger'; import type { LogTransport, LogEntry } from '@granit/logger'; // Custom Sentry transport const sentryTransport: LogTransport = { send(entry: LogEntry) { if (entry.level === 'ERROR') { Sentry.captureMessage(entry.message, { level: 'error', extra: entry.context, }); } }, }; // Combine console + Sentry const logger = createLogger('app', { transports: [createConsoleTransport(), sentryTransport], }); logger.error('Critical failure', new Error('timeout')); // → Both console and Sentry receive the entry ``` Transports with a `flush()` method are automatically flushed on `beforeunload`. ## Further reading [Section titled “Further reading”](#further-reading) * [Strategy — refactoring.guru](https://refactoring.guru/design-patterns/strategy) # Adapter > Interface translation for typed cache keys, S3 storage, and SMTP transport in Granit ## Definition [Section titled “Definition”](#definition) The Adapter pattern converts the interface of an existing class into the interface expected by the client, allowing incompatible components to collaborate. The adapter wraps the existing class and translates calls. ## Diagram [Section titled “Diagram”](#diagram) ``` classDiagram class ICacheService_TCacheItem_TKey { +GetOrAddAsync(key: TKey) } class ICacheService_TCacheItem { +GetOrAddAsync(key: string) } class TypedKeyCacheServiceAdapter { -inner : ICacheService_TCacheItem +GetOrAddAsync(key: TKey) } ICacheService_TCacheItem_TKey <|.. TypedKeyCacheServiceAdapter TypedKeyCacheServiceAdapter --> ICacheService_TCacheItem : delegates via key.ToString() class IBlobStorageClient { +DeleteObjectAsync() +HeadObjectAsync() } class AmazonS3Client { +DeleteObjectAsync() +GetObjectMetadataAsync() } class S3BlobClient { -s3Client : AmazonS3Client } IBlobStorageClient <|.. S3BlobClient S3BlobClient --> AmazonS3Client : adapts ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) | Adapter | File | Target interface | Adapted class | | ----------------------------------------------- | ------------------------------------------------------------- | --------------------------------- | ----------------------------------------- | | `TypedKeyCacheServiceAdapter` | `src/Granit.Caching/TypedKeyCacheServiceAdapter.cs` | `ICacheService` | `ICacheService` (string keys) | | `S3BlobClient` | `src/Granit.BlobStorage.S3/Internal/S3BlobClient.cs` | `IBlobStorageClient` | `AmazonS3Client` (AWS SDK) | | `MailKitSmtpTransport` | `src/Granit.Notifications.Email.Smtp/MailKitSmtpTransport.cs` | `ISmtpTransport` | `SmtpClient` (MailKit, sealed) | ## Rationale [Section titled “Rationale”](#rationale) `TypedKeyCacheServiceAdapter` enables strongly-typed keys (Guid, int, composite) while delegating to the existing string-key-based cache service. `S3BlobClient` isolates the framework from the AWS SDK, allowing provider changes (European hosting, MinIO) without touching the core. ## Usage example [Section titled “Usage example”](#usage-example) ```csharp // Application uses typed keys -- the adapter converts to string ICacheService cache = serviceProvider .GetRequiredService>(); PatientDto patient = await cache.GetOrAddAsync( patientId, // Guid -- converted to string by the adapter async ct => await db.Patients.FindAsync([patientId], ct), cancellationToken); ``` ## Further reading [Section titled “Further reading”](#further-reading) * [Adapter — refactoring.guru](https://refactoring.guru/design-patterns/adapter) # Anti-Corruption Layer > Translation layer isolating domain models from external API models and SDKs ## Definition [Section titled “Definition”](#definition) The Anti-Corruption Layer (ACL) isolates the internal domain model from external models (third-party APIs, SDKs, legacy formats). A translation layer converts external DTOs into Granit domain types, preventing foreign concepts from “polluting” the framework core. ## Diagram [Section titled “Diagram”](#diagram) ``` flowchart LR subgraph External["External services"] KC["Keycloak Admin API"] S3["AWS S3 SDK"] BR["Brevo API"] FCM["Firebase FCM"] MK["MailKit / SMTP"] end subgraph ACL["Anti-Corruption Layer"] KC_DTO["KeycloakUserRepresentation"] KC_MAP["ToIdentityUser()"] S3_ADP["S3BlobClient"] BR_ADP["BrevoNotificationProvider"] FCM_ADP["GoogleFcmMobilePushSender"] MK_ADP["MailKitEmailSender"] end subgraph Domain["Granit domain model"] IU["IdentityUser"] BD["BlobDescriptor"] EM["EmailMessage"] PM["MobilePushMessage"] end KC --> KC_DTO --> KC_MAP --> IU S3 --> S3_ADP --> BD BR --> BR_ADP --> EM FCM --> FCM_ADP --> PM MK --> MK_ADP --> EM style External fill:#fef0f0,stroke:#c44e4e style ACL fill:#fef3e0,stroke:#e8a317 style Domain fill:#e8fde8,stroke:#2d8a4e ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) ### Keycloak — the most comprehensive case [Section titled “Keycloak — the most comprehensive case”](#keycloak--the-most-comprehensive-case) `Granit.Identity.Keycloak` is the framework’s canonical ACL. Keycloak responses are deserialized into `internal` DTOs (`KeycloakUserRepresentation`, `KeycloakSessionRepresentation`, etc.) then converted to domain models via `private static` methods: ```csharp // External DTO (internal, never exposed) internal sealed record KeycloakUserRepresentation( [property: JsonPropertyName("id")] string Id, [property: JsonPropertyName("username")] string Username, [property: JsonPropertyName("email")] string? Email, // ... ); // Conversion to domain model private static IdentityUser ToIdentityUser(KeycloakUserRepresentation user) => new(user.Id, user.Username, user.Email, user.FirstName, user.LastName, user.Enabled, FlattenAttributes(user.Attributes)); ``` **Specifics**: * `FlattenAttributes()` — converts Keycloak `Dictionary(string, List(string))` to Granit `Dictionary(string, string)` (multi-value attributes to single value) * `ToIdentitySession()` — converts Unix timestamps (milliseconds) to `DateTimeOffset` * `ToIdentityGroup()` — recursive mapping for subgroups ### Claims transformation (JWT) [Section titled “Claims transformation (JWT)”](#claims-transformation-jwt) `KeycloakClaimsTransformation` and `EntraIdClaimsTransformation` extract roles from proprietary JSON structures (`realm_access.roles`, `resource_access.{client}.roles`) and convert them to standard .NET `ClaimTypes.Role` claims. ### ACL inventory in Granit [Section titled “ACL inventory in Granit”](#acl-inventory-in-granit) | External service | ACL package | External DTO to internal model | | ------------------ | ------------------------------------------- | ------------------------------------------------------------------- | | Keycloak Admin API | `Granit.Identity.Keycloak` | `KeycloakUserRepresentation` to `IdentityUser` | | Keycloak JWT | `Granit.Authentication.Keycloak` | JSON `realm_access` to `ClaimTypes.Role` | | Entra ID JWT | `Granit.Authentication.EntraId` | JSON `roles` (v1.0/v2.0) to `ClaimTypes.Role` | | AWS S3 SDK | `Granit.BlobStorage.S3` | `GetPreSignedUrlRequest` from `BlobUploadRequest` | | MailKit SMTP | `Granit.Notifications.Email.Smtp` | `MimeMessage` from `EmailMessage` | | Brevo API | `Granit.Notifications.Brevo` | JSON payload from `EmailMessage` / `SmsMessage` / `WhatsAppMessage` | | Firebase FCM | `Granit.Notifications.MobilePush.GoogleFcm` | `FcmPayload` from `MobilePushMessage` | | ImageMagick | `Granit.Imaging.MagickNet` | `MagickFormat` to/from `ImageFormat` (bidirectional) | | Import systems | `Granit.DataExchange.EntityFrameworkCore` | External ID (Odoo `__export__`) to internal Entity ID | ### Architectural principles [Section titled “Architectural principles”](#architectural-principles) 1. **External DTOs are `internal`** — never exposed outside the adapter package 2. **Conversion methods are `private static`** — isolated from the rest of the code 3. **Error transformation** — external errors are parsed and encapsulated in domain exceptions 4. **Graceful degradation** — reads log a warning and return `null`; writes propagate the exception 5. **`[ExcludeFromCodeCoverage]`** — on adapters requiring a live service (S3, SMTP) ### Reference files [Section titled “Reference files”](#reference-files) | File | Role | | ------------------------------------------------------------------------------------- | ---------------------------------- | | `src/Granit.Identity.Keycloak/Internal/KeycloakIdentityProvider.cs` | Primary ACL (9 conversion methods) | | `src/Granit.Identity.Keycloak/Internal/KeycloakUserRepresentation.cs` | Keycloak external DTO | | `src/Granit.Authentication.Keycloak/Authentication/KeycloakClaimsTransformation.cs` | JWT claims to Role | | `src/Granit.BlobStorage.S3/Internal/S3BlobClient.cs` | S3 adapter | | `src/Granit.Notifications.Email.Smtp/Internal/MailKitEmailSender.cs` | SMTP adapter | | `src/Granit.Notifications.Brevo/Internal/BrevoNotificationProvider.cs` | Multi-channel Brevo | | `src/Granit.Notifications.MobilePush.GoogleFcm/Internal/GoogleFcmMobilePushSender.cs` | FCM adapter | | `src/Granit.Imaging.MagickNet/Internal/MagickFormatMapper.cs` | Image format mapping | ## Rationale [Section titled “Rationale”](#rationale) | Problem | ACL solution | | ------------------------------------------------------------ | ---------------------------------------------------------- | | Keycloak models in the domain = tight coupling | `internal` DTOs + static conversion | | Keycloak API change = impact across the entire codebase | Only the adapter changes, the domain remains stable | | JWT claims format differs between Keycloak and Entra ID | Dedicated transformations, unified `ClaimTypes.Role` model | | Multi-value Keycloak attributes vs single-value Granit | `FlattenAttributes()` in the ACL | | Unix timestamps (ms) in Keycloak vs `DateTimeOffset` in .NET | Conversion in `ToIdentitySession()` | ## Usage example [Section titled “Usage example”](#usage-example) ```csharp // The endpoint only knows the IdentityUser domain model, // never the Keycloak DTOs private static async Task, NotFound>> GetUserAsync( string userId, IIdentityProvider identityProvider, CancellationToken cancellationToken) { // IIdentityProvider is implemented by KeycloakIdentityProvider // which does: API call > KeycloakUserRepresentation > ToIdentityUser() IdentityUser? user = await identityProvider .FindByIdAsync(userId, cancellationToken) .ConfigureAwait(false); return user is not null ? TypedResults.Ok(user) : TypedResults.NotFound(); } ``` ## Further reading [Section titled “Further reading”](#further-reading) * [Anti-Corruption Layer — Microsoft Cloud Design Patterns](https://learn.microsoft.com/en-us/azure/architecture/patterns/anti-corruption-layer) # Builder > Fluent AddGranit*() extension methods for composable module registration ## Definition [Section titled “Definition”](#definition) The Builder pattern separates the construction of a complex object from its representation, enabling different configurations via a fluent interface. In Granit, this pattern manifests in the `AddGranit*()` extension methods that configure services module by module. ## Diagram [Section titled “Diagram”](#diagram) ``` sequenceDiagram participant App as Program.cs participant Ext as AddGranitWolverine() participant Opts as WolverineMessagingOptions participant DI as IServiceCollection participant Val as ValidateOnStart App->>Ext: builder.AddGranitWolverine(configure?) Ext->>Opts: AddOptions().BindConfiguration("Wolverine") Ext->>Val: ValidateDataAnnotations().ValidateOnStart() Ext->>DI: AddScoped of ICurrentUserService Ext->>DI: AddSingleton of WolverineActivitySource opt configure provided Ext->>Opts: configure.Invoke(options) end Ext-->>App: IHostApplicationBuilder (fluent) ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) Each module exposes an `AddGranit*()` extension method: | Extension | File | Receiver | | --------------------------- | ---------------------------------------------------------------------------------------- | ------------------------- | | `AddGranit()` | `src/Granit.Core/Extensions/GranitHostBuilderExtensions.cs` | `IHostApplicationBuilder` | | `AddGranitWolverine()` | `src/Granit.Wolverine/Extensions/WolverineHostApplicationBuilderExtensions.cs` | `IHostApplicationBuilder` | | `AddGranitBackgroundJobs()` | `src/Granit.BackgroundJobs/Extensions/BackgroundJobsHostApplicationBuilderExtensions.cs` | `IHostApplicationBuilder` | | `AddGranitFeatures()` | `src/Granit.Features/ServiceCollectionExtensions.cs` | `IServiceCollection` | | `AddGranitLocalization()` | `src/Granit.Localization/Extensions/LocalizationServiceCollectionExtensions.cs` | `IServiceCollection` | **Audit note**: the signatures are not yet symmetric across modules (see finding C2 in the critical dashboard). The target is the `AddOptions().BindConfiguration().ValidateOnStart()` pattern. ## Rationale [Section titled “Rationale”](#rationale) The Builder allows each NuGet package to self-configure without the host application needing to know internal details. A single call replaces dozens of DI registration lines. ## Usage example [Section titled “Usage example”](#usage-example) ```csharp WebApplicationBuilder builder = WebApplication.CreateBuilder(args); // One call per module -- fluent and composable builder.AddGranit(); // Internally, the ModuleLoader calls AddGranitWolverine(), // AddGranitPersistence(), AddGranitFeatures(), etc. // in topological dependency order ``` ## Further reading [Section titled “Further reading”](#further-reading) * [Builder — refactoring.guru](https://refactoring.guru/design-patterns/builder) # Bulkhead Isolation > Resource compartmentalization preventing cascade failures across queues, tenants, and services ## Definition [Section titled “Definition”](#definition) The **Bulkhead** (watertight compartment) isolates system resources into independent compartments, so that a failure or overload in one compartment does not propagate to others. In a multi-tenant SaaS context, the pattern prevents a greedy tenant, a slow notification channel, or a failing external service from degrading the entire platform. Granit implements this pattern through a combination of mechanisms: Wolverine queue partitioning, per-pipeline parallelism limits, per-tenant quota isolation, and HTTP circuit breakers. ## Diagram [Section titled “Diagram”](#diagram) ``` flowchart TB subgraph Bulkheads direction LR B1[Queue domain-events
Parallelism: local] B2[Queue notification-delivery
MaxParallel: 8] B3[Queue webhook-delivery
MaxParallel: 20] B4[HttpClient Keycloak
Circuit Breaker] B5[HttpClient Brevo
Circuit Breaker] end REQ[Incoming requests] --> B1 REQ --> B2 REQ --> B3 REQ --> B4 REQ --> B5 B3 --x|Saturated| B3 B3 -.->|No impact| B1 B3 -.->|No impact| B2 ``` ``` sequenceDiagram participant T1 as Tenant A (high load) participant RL as RateLimiter per-tenant participant Q as Queue webhook-delivery participant T2 as Tenant B (normal load) T1->>RL: 500 webhooks/min RL-->>T1: 429 Too Many Requests (quota exceeded) Note over RL: Tenant A isolated by its quota T2->>RL: 10 webhooks/min RL-->>Q: Allowed Q->>Q: Processing (max 20 parallel) Note over T2,Q: Tenant B unaffected ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) Granit implements Bulkhead Isolation through 5 complementary mechanisms, each targeting a different isolation level: ### 1. Queue partitioning — isolation by message type (Wolverine) [Section titled “1. Queue partitioning — isolation by message type (Wolverine)”](#1-queue-partitioning--isolation-by-message-type-wolverine) Messages are routed to dedicated queues with controlled parallelism. Each queue operates as an independent compartment. | Queue | Messages | Isolation | | ----------------------- | ---------------------------- | ------------------------------------------------- | | `domain-events` | `IDomainEvent` | Local only, never routed to an external transport | | `notification-delivery` | `DeliverNotificationCommand` | Configurable parallelism (default: 8) | | `webhook-delivery` | `SendWebhookCommand` | Configurable parallelism (default: 20) | | Error queue (DLQ) | `ValidationException` | Deterministic failures, no retry | ```csharp // Explicit routing to local queue (AddGranitWolverine) opts.PublishMessage() .ToLocalQueue("domain-events"); ``` ### 2. MaxParallelDeliveries — concurrency limits per pipeline [Section titled “2. MaxParallelDeliveries — concurrency limits per pipeline”](#2-maxparalleldeliveries--concurrency-limits-per-pipeline) Each delivery pipeline limits the number of simultaneous operations, preventing a saturated channel from consuming all pod resources. | Module | Parameter | Default | Range | | ---------------------- | ----------------------- | ------- | ----- | | `Granit.Notifications` | `MaxParallelDeliveries` | 8 | 1—100 | | `Granit.Webhooks` | `MaxParallelDeliveries` | 20 | 1—100 | ### 3. Per-tenant rate limiting — quota isolation by tenant [Section titled “3. Per-tenant rate limiting — quota isolation by tenant”](#3-per-tenant-rate-limiting--quota-isolation-by-tenant) `Granit.RateLimiting` partitions Redis counters by tenant. Each tenant has its own independent counters — a tenant can never consume another’s quota. | Element | Detail | | --------- | ---------------------------------------------------- | | Redis key | `{prefix}:{tenantId}:{policyName}` | | Hash tag | `{tenantId}` guarantees co-location in Redis Cluster | | Bypass | Configurable exempt roles | ### 4. Circuit breaker — external HTTP service isolation [Section titled “4. Circuit breaker — external HTTP service isolation”](#4-circuit-breaker--external-http-service-isolation) `AddStandardResilienceHandler()` (Microsoft.Extensions.Http.Resilience) is applied to each `HttpClient` targeting an external service. When an external service is down, the circuit opens and requests fail immediately without consuming resources. | Service | Circuit Breaker | Retry | Timeout | | ------------------ | --------------- | ------------------------------- | ---------- | | Keycloak Admin API | Yes | 3 attempts, exponential backoff | 30s / 2min | | Brevo API | Yes | 3 attempts, exponential backoff | 30s / 2min | ```csharp // Each external HttpClient is isolated by its own circuit breaker services.AddHttpClient("keycloak-admin") .AddStandardResilienceHandler(); ``` ### 5. SemaphoreSlim — anti-stampede per resource [Section titled “5. SemaphoreSlim — anti-stampede per resource”](#5-semaphoreslim--anti-stampede-per-resource) Shared resources (token cache, distributed cache) use `SemaphoreSlim(1, 1)` to serialize concurrent access during a cache miss. This mechanism prevents a request spike from generating N parallel calls to the same external service. | Component | Protected resource | Pattern | | --------------------------- | ------------------ | -------------------- | | `KeycloakAdminTokenService` | Keycloak token | Double-check locking | | `DistributedCacheService` | Distributed cache | Double-check locking | ### 6. Channel-based isolation — Webhooks [Section titled “6. Channel-based isolation — Webhooks”](#6-channel-based-isolation--webhooks) The `WebhookDispatchWorker` uses two separate `System.Threading.Channels.Channel(T)` to isolate the fan-out phase (trigger to commands) from the delivery phase (command to HTTP POST). Both phases execute in parallel via `Task.WhenAll()` without interference. ### Reference files [Section titled “Reference files”](#reference-files) | File | Role | | ------------------------------------------------------------------------------ | ---------------------------------- | | `src/Granit.Wolverine/Extensions/WolverineHostApplicationBuilderExtensions.cs` | Queue routing for domain-events | | `src/Granit.RateLimiting/Internal/TenantPartitionedRateLimiter.cs` | Per-tenant isolation | | `src/Granit.Identity.Keycloak/Internal/KeycloakAdminTokenService.cs` | SemaphoreSlim anti-stampede | | `src/Granit.Caching/DistributedCacheService.cs` | SemaphoreSlim cache miss | | `src/Granit.Webhooks/Internal/WebhookDispatchWorker.cs` | Separate trigger/delivery channels | ## Rationale [Section titled “Rationale”](#rationale) | Problem | Solution | | ------------------------------------------------- | ---------------------------------------------- | | Webhook to a slow service blocks notifications | Separate queues with independent parallelism | | Greedy tenant saturates the API for everyone | Quota counters partitioned by tenant | | Down external service consumes threads | Circuit breaker cuts calls after threshold | | Simultaneous cache miss = N calls to the provider | SemaphoreSlim serializes, only one call passes | | Webhook fan-out blocks the delivery phase | Separate channels for trigger vs delivery | ## Usage example [Section titled “Usage example”](#usage-example) ```csharp // --- Isolation by Wolverine queue --- // IDomainEvent -> dedicated local queue, no interference with integration events opts.PublishMessage() .ToLocalQueue("domain-events"); // --- Configurable parallelism per module --- // appsettings.json // { // "Webhooks": { "MaxParallelDeliveries": 20 }, // "Notifications": { "MaxParallelDeliveries": 8 } // } // --- Circuit breaker per external service --- services.AddHttpClient("keycloak-admin", client => client.BaseAddress = new Uri(keycloakUrl)) .AddStandardResilienceHandler(); // --- Per-tenant rate limiting (automatic isolation) --- app.MapGet("/api/v1/patients", GetPatientsAsync) .RequireGranitRateLimiting("api"); // Each tenant has its own Redis counters -- independent quota ``` ## Further reading [Section titled “Further reading”](#further-reading) * [Bulkhead pattern — Microsoft Cloud Design Patterns](https://learn.microsoft.com/en-us/azure/architecture/patterns/bulkhead) # Cache-Aside > Lazy-loading cache with HybridCache (L1 + L2 Redis) and anti-stampede protection ## Definition [Section titled “Definition”](#definition) The Cache-Aside pattern loads data into the cache on demand: on a miss, data is retrieved from the source (DB), stored in cache, then returned. Subsequent accesses are served from the cache. Granit uses a **HybridCache** (L1 in-process + L2 Redis) with anti-stampede protection via double-check locking. ## Diagram [Section titled “Diagram”](#diagram) ``` flowchart TD REQ[GetOrAddAsync] --> L1{L1 Memory Cache} L1 -->|hit| RET[Return value] L1 -->|miss| L2{L2 Redis Cache} L2 -->|hit| SET1[Store in L1] --> RET L2 -->|miss| LOCK[Acquire SemaphoreSlim] LOCK --> DC{Double-check L2} DC -->|hit| REL1[Release lock] --> SET1 DC -->|miss| FAC[Execute factory
= DB query] FAC --> SET2[Store in L1 + L2] SET2 --> REL2[Release lock] --> RET ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) | Component | File | Role | | --------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------- | | `DistributedCacheService` | `src/Granit.Caching/DistributedCacheService.cs` | Cache-aside with double-check locking and optional encryption | | `FeatureChecker` | `src/Granit.Features/Checker/FeatureChecker.cs` | HybridCache for feature resolution | | `CachedLocalizationOverrideStore` | `src/Granit.Localization/CachedLocalizationOverrideStore.cs` | In-memory cache for localization overrides | ### Anti-stampede [Section titled “Anti-stampede”](#anti-stampede) The `SemaphoreSlim` in `DistributedCacheService` prevents the “thundering herd” problem: when 100 simultaneous requests have a cache miss, only one executes the factory. The other 99 wait for the lock then find the value in cache (double-check). ### Per-tenant keys [Section titled “Per-tenant keys”](#per-tenant-keys) In `FeatureChecker`, cache keys include the tenant: `t:{tenantId}:{featureName}`. Invalidation targets only the affected tenant. ## Rationale [Section titled “Rationale”](#rationale) | Problem | Solution | | ------------------------------------------------------- | ----------------------------------------------------- | | Feature resolution too slow (DB query on every request) | L1 cache (nanoseconds) + L2 Redis (microseconds) | | Stampede on cache miss (100 requests = 100 DB queries) | SemaphoreSlim + double-check locking | | Sensitive data in Redis cache | Conditional AES-256 encryption via `[CacheEncrypted]` | ## Usage example [Section titled “Usage example”](#usage-example) ```csharp // Cache-aside is transparent to the caller ICacheService cache = serviceProvider .GetRequiredService>(); PatientDto patient = await cache.GetOrAddAsync( $"patient:{patientId}", async ct => await LoadPatientFromDbAsync(patientId, ct), cancellationToken); // 1st call -> DB + stores in cache // 2nd call -> returned from cache (L1 or L2) ``` ## Further reading [Section titled “Further reading”](#further-reading) * [Cache-Aside pattern — Microsoft Cloud Design Patterns](https://learn.microsoft.com/en-us/azure/architecture/patterns/cache-aside) # Chain of Responsibility > Ordered handler pipeline for tenant resolution and blob validation in Granit ## Definition [Section titled “Definition”](#definition) The Chain of Responsibility pattern passes a request along an ordered chain of handlers. Each handler decides whether to process the request or forward it to the next one. The first capable handler short-circuits the chain. ## Diagram [Section titled “Diagram”](#diagram) ``` sequenceDiagram participant P as TenantResolverPipeline participant HR as HeaderTenantResolver (100) participant JR as JwtClaimTenantResolver (200) participant CR as CustomResolver (300) P->>HR: ResolveAsync(httpContext) alt X-Tenant-Id header present HR-->>P: TenantInfo (short-circuit) else Header absent HR-->>P: null P->>JR: ResolveAsync(httpContext) alt JWT claim present JR-->>P: TenantInfo (short-circuit) else Claim absent JR-->>P: null P->>CR: ResolveAsync(httpContext) CR-->>P: TenantInfo or null end end ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) ### Tenant resolution [Section titled “Tenant resolution”](#tenant-resolution) | Component | File | Role | | ------------------------ | ------------------------------------------------------------- | --------------------------------------------------------- | | `TenantResolverPipeline` | `src/Granit.MultiTenancy/Pipeline/TenantResolverPipeline.cs` | Iterates `ITenantResolver` instances by ascending `Order` | | `HeaderTenantResolver` | `src/Granit.MultiTenancy/Resolvers/HeaderTenantResolver.cs` | Order=100, reads `X-Tenant-Id` | | `JwtClaimTenantResolver` | `src/Granit.MultiTenancy/Resolvers/JwtClaimTenantResolver.cs` | Order=200, reads the JWT claim | ### Blob validation [Section titled “Blob validation”](#blob-validation) | Component | File | Order | Role | | --------------------- | ---------------------------------------------------------- | ----- | ----------------------------- | | `MagicBytesValidator` | `src/Granit.BlobStorage/Validators/MagicBytesValidator.cs` | 10 | Verifies the actual MIME type | | `MaxSizeValidator` | `src/Granit.BlobStorage/Validators/MaxSizeValidator.cs` | 20 | Verifies the file size | The blob validation pipeline is a special case: **all** validators are executed (no short-circuit), but the order determines error message priority. ## Rationale [Section titled “Rationale”](#rationale) The tenant resolution chain allows adding resolvers (query string, cookie, subdomain) without modifying existing code. The ordering via the `Order` property is configurable without recompilation. ## Usage example [Section titled “Usage example”](#usage-example) ```csharp // Add a custom resolver -- inserts into the chain by Order public sealed class SubdomainTenantResolver : ITenantResolver { public int Order => 50; // Before HeaderTenantResolver (100) public Task ResolveAsync(HttpContext context, CancellationToken cancellationToken) { string host = context.Request.Host.Host; // Extract tenant from subdomain... return Task.FromResult(tenantInfo); } } // Registration services.AddSingleton(); ``` ## Further reading [Section titled “Further reading”](#further-reading) * [Chain of Responsibility — refactoring.guru](https://refactoring.guru/design-patterns/chain-of-responsibility) # Circuit Breaker and Retry > Resilience for HTTP clients and async messaging with exponential backoff and automatic recovery ## Definition [Section titled “Definition”](#definition) The **Circuit Breaker** cuts calls to a failing service to prevent cascade saturation. **Retry** automatically replays failed requests with exponential backoff. Granit combines both via `Microsoft.Extensions.Http.Resilience` for outgoing HTTP calls and Wolverine `RetryWithCooldown` for asynchronous messages. ## Diagram [Section titled “Diagram”](#diagram) ``` stateDiagram-v2 [*] --> Closed Closed --> Open : Failure rate > threshold Open --> HalfOpen : Timeout expired HalfOpen --> Closed : Test request succeeded HalfOpen --> Open : Test request failed state Closed { [*] --> Normal Normal --> Retry : Transient failure Retry --> Normal : Success Retry --> Retry : Exponential backoff } ``` ``` sequenceDiagram participant S as Granit Service participant R as Resilience Handler participant E as External Service S->>R: POST /api/send-email R->>E: Attempt 1 E-->>R: 503 Service Unavailable R->>R: Backoff 1s R->>E: Attempt 2 E-->>R: 503 Service Unavailable R->>R: Backoff 2s R->>E: Attempt 3 E-->>R: 200 OK R-->>S: 200 OK ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) ### HTTP — AddStandardResilienceHandler [Section titled “HTTP — AddStandardResilienceHandler”](#http--addstandardresiliencehandler) Each `HttpClient` targeting an external service is configured with the .NET standard resilience handler: | Service | Registration file | | -------------------------- | ------------------------------------------------------------------------------------------------------------ | | Keycloak Admin API | `src/Granit.Identity.Keycloak/Extensions/IdentityKeycloakServiceCollectionExtensions.cs` | | Microsoft Graph (Entra ID) | `src/Granit.Identity.EntraId/Extensions/IdentityEntraIdServiceCollectionExtensions.cs` | | Brevo (email/SMS/WhatsApp) | `src/Granit.Notifications.Brevo/Extensions/BrevoNotificationsServiceCollectionExtensions.cs` | | Zulip (chat) | `src/Granit.Notifications.Zulip/Extensions/ZulipNotificationsServiceCollectionExtensions.cs` | | Firebase FCM (push) | `src/Granit.Notifications.MobilePush.GoogleFcm/Extensions/GoogleFcmMobilePushServiceCollectionExtensions.cs` | ```csharp services.AddHttpClient("KeycloakAdmin", client => { client.BaseAddress = new Uri(options.BaseUrl); client.Timeout = TimeSpan.FromSeconds(options.TimeoutSeconds); }) .AddStandardResilienceHandler(); ``` `AddStandardResilienceHandler()` automatically adds: * **Retry** — 3 attempts, exponential backoff on transient errors (429, 5xx) * **Circuit Breaker** — opens after exceeding the failure threshold over 30s * **Timeout** — per request (30s) and total (2min) * **Rate Limiter** — concurrency control ### Messaging — Wolverine RetryWithCooldown [Section titled “Messaging — Wolverine RetryWithCooldown”](#messaging--wolverine-retrywithcooldown) For asynchronous messages, Wolverine provides retry with progressive cooldown. Example with webhooks (6 levels, 30s to 12h): ```csharp opts.OnException() .RetryWithCooldown( TimeSpan.FromSeconds(30), // Level 1 TimeSpan.FromMinutes(2), // Level 2 TimeSpan.FromMinutes(10), // Level 3 TimeSpan.FromMinutes(30), // Level 4 TimeSpan.FromHours(2), // Level 5 TimeSpan.FromHours(12)); // Level 6 -> Dead-Letter Queue ``` ### Resilience matrix by service [Section titled “Resilience matrix by service”](#resilience-matrix-by-service) | Service | HTTP Resilience | Messaging retry | Special behavior | | -------------- | ---------------------- | --------------------- | ----------------------------- | | Keycloak Admin | Standard handler | — | Graceful degradation on reads | | Brevo | Standard handler | Wolverine retry | — | | SMTP | Configurable timeout | Wolverine retry | — | | Web Push | Standard handler | Wolverine retry | Auto-cleanup on HTTP 410 | | Webhooks | Timeout 5-120s | 6 levels (30s to 12h) | Auto-suspend on 401/403/410 | | Vault | — | Lease renewal | Auto-refresh credentials | | S3 | AWS SDK built-in retry | — | Native SDK backoff | | OTLP | — | Buffer batch export | — | ### Configurable timeouts [Section titled “Configurable timeouts”](#configurable-timeouts) Each external service exposes a timeout via the Options pattern: | Options | Property | Default | Range | | ---------------------- | -------------------- | ------- | ----- | | `KeycloakAdminOptions` | `TimeoutSeconds` | 30 | — | | `BrevoOptions` | `TimeoutSeconds` | 30 | 1—300 | | `SmtpOptions` | `TimeoutSeconds` | 30 | — | | `WebhooksOptions` | `HttpTimeoutSeconds` | 10 | 5—120 | ### Reference files [Section titled “Reference files”](#reference-files) | File | Role | | -------------------------------------------------------------------------------------------- | ------------------------------- | | `src/Granit.Identity.Keycloak/Extensions/IdentityKeycloakServiceCollectionExtensions.cs` | Standard resilience on Keycloak | | `src/Granit.Notifications.Brevo/Extensions/BrevoNotificationsServiceCollectionExtensions.cs` | Standard resilience on Brevo | | `src/Granit.Webhooks/Extensions/WebhooksHostApplicationBuilderExtensions.cs` | RetryWithCooldown 6 levels | ## Rationale [Section titled “Rationale”](#rationale) | Problem | Solution | | --------------------------------------------------- | ------------------------------------------------------------------ | | Temporarily down external service = cascade of 500s | Circuit Breaker cuts calls, prevents saturation | | Transient network error = data loss | Retry with backoff replays automatically | | Webhook endpoint down for hours | 6 progressive levels (30s to 12h) before dead-letter | | Expired token on an external service | Auto-refresh via Vault lease renewal | | `new HttpClient()` without resilience | `IHttpClientFactory` + systematic `AddStandardResilienceHandler()` | ## Usage example [Section titled “Usage example”](#usage-example) ```csharp // --- Registration with standard resilience --- services.AddHttpClient(client => { client.BaseAddress = new Uri("https://api.geo.example.com"); }) .AddStandardResilienceHandler(); // --- The service has no awareness of the resilience --- public sealed class GeoService(HttpClient httpClient) { public async Task GeocodeAsync( string address, CancellationToken cancellationToken = default) { // Retry + Circuit Breaker + Timeout are transparent return await httpClient .GetFromJsonAsync( $"/geocode?q={Uri.EscapeDataString(address)}", cancellationToken) .ConfigureAwait(false); } } ``` ## Further reading [Section titled “Further reading”](#further-reading) * [Circuit Breaker pattern — Microsoft Cloud Design Patterns](https://learn.microsoft.com/en-us/azure/architecture/patterns/circuit-breaker) * [Retry pattern — Microsoft Cloud Design Patterns](https://learn.microsoft.com/en-us/azure/architecture/patterns/retry) # Claim Check > Replace large message payloads with lightweight references to external storage ## Definition [Section titled “Definition”](#definition) The **Claim Check** (or Reference-Based Messaging) replaces large payloads in messages with a lightweight reference to external storage. The producer serializes the payload, stores it in a blob store or cache, and sends only a reference identifier. The consumer uses this reference to retrieve the full payload before processing. This pattern reduces message size, decreases pressure on the message bus, and avoids transport size limit overflows. ## Diagram [Section titled “Diagram”](#diagram) ``` sequenceDiagram participant P as Producer participant S as IClaimCheckStore participant Bus as Wolverine Bus participant C as Consumer P->>S: StorePayloadAsync(largePayload) S-->>P: ClaimCheckReference (Guid) P->>Bus: Command (Reference) Bus->>C: HandleAsync(command) C->>S: RetrievePayloadAsync(reference) S-->>C: largePayload C->>C: Processing C->>S: DeleteAsync(reference) [optional] ``` ``` flowchart LR A[Large payload] --> B{Size > threshold?} B -- Yes --> C[Store -> Reference] B -- No --> D[Direct message] C --> E[Lightweight message + reference] E --> F[Consumer retrieve] F --> G[Processing] ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) Granit provides an `IClaimCheckStore` abstraction in `Granit.Wolverine` with a **soft dependency**: if no implementation is registered in the DI container, the feature is simply unavailable. Handlers resolve the store via `IServiceProvider.GetService()`. ### Abstraction [Section titled “Abstraction”](#abstraction) | Element | Detail | | --------------- | ------------------------------------------------------ | | Interface | `IClaimCheckStore` | | Package | `Granit.Wolverine` | | Methods | `StoreAsync`, `RetrieveAsync`, `DeleteAsync` | | Soft dependency | Resolved via `GetService()`, no `[DependsOn]` required | ```csharp public interface IClaimCheckStore { Task StoreAsync( ReadOnlyMemory data, string? contentType = null, TimeSpan? expiry = null, CancellationToken cancellationToken = default); Task RetrieveAsync( Guid referenceId, CancellationToken cancellationToken = default); Task DeleteAsync( Guid referenceId, CancellationToken cancellationToken = default); } ``` ### Typed extensions [Section titled “Typed extensions”](#typed-extensions) `ClaimCheckExtensions` handle JSON serialization automatically: | Method | Role | | ------------------------- | --------------------------------------------------- | | `StorePayloadAsync(T)` | Serializes to UTF-8 JSON and stores | | `RetrievePayloadAsync(T)` | Retrieves and deserializes | | `ConsumePayloadAsync(T)` | Retrieves, deserializes, and deletes (consume-once) | ### Reference [Section titled “Reference”](#reference) `ClaimCheckReference` is an immutable record carrying the storage identifier, the payload type, and the content type: ```csharp public sealed record ClaimCheckReference( Guid ReferenceId, string PayloadType, string ContentType = "application/json"); ``` ### Available implementations [Section titled “Available implementations”](#available-implementations) | Implementation | Package | Usage | | --------------------------- | ------------------ | ---------------------------------- | | `InMemoryClaimCheckStore` | `Granit.Wolverine` | Development and tests | | BlobStorage-backed (custom) | Application | Production (S3, Azure Blob, Redis) | The in-memory implementation uses a `ConcurrentDictionary` and does not enforce expiry. In production, the application registers its own implementation via DI. ### Soft dependency pattern [Section titled “Soft dependency pattern”](#soft-dependency-pattern) The Claim Check follows the same pattern as `IFeatureChecker` in `Granit.RateLimiting`: 1. The interface is defined in the framework package (`Granit.Wolverine`) 2. No implementation is registered by default by `AddGranitWolverine()` 3. Handlers that need it resolve via `GetService()` 4. If `Granit.BlobStorage` is installed, the application can register a store backed by blob storage 5. If no store is registered, the feature is disabled ### Reference files [Section titled “Reference files”](#reference-files) | File | Role | | -------------------------------------------------------------------------- | ------------------ | | `src/Granit.Wolverine/ClaimCheck/IClaimCheckStore.cs` | Store abstraction | | `src/Granit.Wolverine/ClaimCheck/ClaimCheckReference.cs` | Reference record | | `src/Granit.Wolverine/ClaimCheck/ClaimCheckExtensions.cs` | Typed JSON helpers | | `src/Granit.Wolverine/ClaimCheck/Internal/InMemoryClaimCheckStore.cs` | Dev/test store | | `src/Granit.Wolverine/ClaimCheck/ClaimCheckServiceCollectionExtensions.cs` | DI registration | ## Rationale [Section titled “Rationale”](#rationale) | Problem | Solution | | ------------------------------------------------ | ------------------------------------------------------------------- | | Wolverine message > 1 MB = transport pressure | Payload stored externally, message reduced to a Guid | | GDPR export with large data in saga state | Only the `BlobReferenceId` is stored (ISO 27001) | | Tight coupling between Wolverine and BlobStorage | Soft dependency — works without BlobStorage installed | | Temporary payload forgotten in the store | `ConsumePayloadAsync` (consume-once) + configurable TTL | | Manual serialization/deserialization | Typed extensions `StorePayloadAsync(T)` / `RetrievePayloadAsync(T)` | ## Usage example [Section titled “Usage example”](#usage-example) ```csharp // --- Producer: offload a large payload --- public sealed record ProcessMedicalRecordCommand( Guid PatientId, ClaimCheckReference RecordDataRef); // In a handler or service IClaimCheckStore claimCheckStore = serviceProvider .GetService() ?? throw new InvalidOperationException("Claim check store not configured."); MedicalRecordData largePayload = await BuildLargePayloadAsync(patientId, cancellationToken) .ConfigureAwait(false); ClaimCheckReference reference = await claimCheckStore .StorePayloadAsync(largePayload, TimeSpan.FromHours(1), cancellationToken) .ConfigureAwait(false); await messageBus.PublishAsync( new ProcessMedicalRecordCommand(patientId, reference), cancellationToken).ConfigureAwait(false); // --- Consumer: retrieve and consume --- public static async Task HandleAsync( ProcessMedicalRecordCommand command, IClaimCheckStore claimCheckStore, CancellationToken cancellationToken) { MedicalRecordData? data = await claimCheckStore .ConsumePayloadAsync( command.RecordDataRef, cancellationToken) .ConfigureAwait(false) ?? throw new InvalidOperationException("Payload expired or already consumed."); // Process the medical record... } // --- DI registration (application) --- // Development: builder.Services.AddInMemoryClaimCheckStore(); // Production (custom implementation): builder.Services.AddSingleton(); ``` ## Further reading [Section titled “Further reading”](#further-reading) * [Claim-Check pattern — Microsoft Cloud Design Patterns](https://learn.microsoft.com/en-us/azure/architecture/patterns/claim-check) * [Enterprise Integration Patterns — Claim Check](https://www.enterpriseintegrationpatterns.com/patterns/messaging/StoreInLibrary.html) # Claims-Based Identity / RBAC > How Granit combines JWT Keycloak authentication with strict role-based access control ## Definition [Section titled “Definition”](#definition) The Claims-Based Identity pattern represents a user’s identity as claims (key-value pairs) extracted from a JWT token. RBAC (Role-Based Access Control) restricts access by verifying that the user’s role holds the required permissions. Granit combines JWT Keycloak + strict RBAC (permissions on roles only, never per user) with a dynamic policy system. ## Diagram [Section titled “Diagram”](#diagram) ``` sequenceDiagram participant C as Client participant KC as Keycloak participant API as Granit API participant CT as ClaimsTransformation participant PC as PermissionChecker participant PS as PermissionGrantStore C->>KC: Authentication KC-->>C: JWT (access_token) C->>API: Request + Bearer token API->>CT: KeycloakClaimsTransformation CT->>CT: Extract realm_access.roles from JWT CT->>CT: Add roles as claims API->>PC: Check permission "Patients.Create" PC->>PS: GetGrantedPermissionsAsync(roles, tenantId) PS-->>PC: Permission list alt Permission granted PC-->>API: true else Permission denied PC-->>API: false -- 403 Forbidden end ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) ### Authentication layer [Section titled “Authentication layer”](#authentication-layer) | Component | File | Role | | ------------------------------ | ----------------------------------------------------------------------------------- | --------------------------------------------------------- | | `ICurrentUserService` | `src/Granit.Security/ICurrentUserService.cs` | `UserId`, `UserName`, `Email`, `GetRoles()`, `IsInRole()` | | `KeycloakClaimsTransformation` | `src/Granit.Authentication.Keycloak/Authentication/KeycloakClaimsTransformation.cs` | Extracts `realm_access.roles` from the Keycloak JWT | | `WolverineCurrentUserService` | `src/Granit.Wolverine/Internal/WolverineCurrentUserService.cs` | `AsyncLocal` fallback for background handlers | ### Authorization layer [Section titled “Authorization layer”](#authorization-layer) | Component | File | Role | | --------------------------------- | ----------------------------------------------------------------------------------- | -------------------------------------------------------------- | | `DynamicPermissionPolicyProvider` | `src/Granit.Authorization/Authorization/DynamicPermissionPolicyProvider.cs` | Creates `AuthorizationPolicy` on the fly from permission names | | `PermissionChecker` | `src/Granit.Authorization/Services/PermissionChecker.cs` | Evaluates permissions against role grants | | `IPermissionDefinitionProvider` | `src/Granit.Authorization/Definitions/IPermissionDefinitionProvider.cs` | Permission declaration (code-first) | | `EfCorePermissionGrantStore` | `src/Granit.Authorization.EntityFrameworkCore/Stores/EfCorePermissionGrantStore.cs` | EF Core grant persistence | ### Strict RBAC [Section titled “Strict RBAC”](#strict-rbac) * Permissions are assigned to **roles**, never to individual users * Cache is per **role** (not per user) for performance * `AdminRoles` bootstraps roles with all permissions at startup ## Rationale [Section titled “Rationale”](#rationale) | Problem | Solution | | ---------------------------------------------------------- | ------------------------------------------------------------- | | Keycloak returns roles in a custom format (`realm_access`) | `KeycloakClaimsTransformation` normalizes to standard claims | | Background handlers have no `HttpContext` | `WolverineCurrentUserService` maintains user via `AsyncLocal` | | Creating one policy per permission would be explosive | `DynamicPermissionPolicyProvider` creates policies on demand | | Per-user grants do not scale (10K users x 100 permissions) | Strict RBAC: grants on roles (10 roles x 100 permissions) | ## Usage example [Section titled “Usage example”](#usage-example) ```csharp // Declare permissions (code-first) public sealed class PatientPermissionDefinitionProvider : IPermissionDefinitionProvider { public void DefinePermissions(IPermissionDefinitionContext context) { PermissionGroup group = context.AddGroup("Patients"); group.AddPermission("Patients.Create"); group.AddPermission("Patients.Read"); group.AddPermission("Patients.Delete"); } } ``` > Localized `displayName` values are optional in this simplified example. See the full authorization documentation for adding `LocalizableString`. ```csharp // Protect an endpoint app.MapPost("/api/patients", CreatePatientEndpoint.Handle) .RequireAuthorization("Patients.Create"); // Programmatic check public static class DischargePatientHandler { public static async Task Handle( DischargePatientCommand cmd, IPermissionChecker permissionChecker, CancellationToken cancellationToken) { bool canDischarge = await permissionChecker.IsGrantedAsync("Patients.Discharge", ct); if (!canDischarge) throw new ForbiddenException(); } } ``` ## Further reading [Section titled “Further reading”](#further-reading) * [Federated Identity pattern — Microsoft Cloud Design Patterns](https://learn.microsoft.com/en-us/azure/architecture/patterns/federated-identity) # Command > Wolverine message-based commands with transactional outbox in Granit ## Definition [Section titled “Definition”](#definition) The Command pattern encapsulates a request as an object, enabling parameterization, queuing, logging, and undo of operations. The command is a serializable DTO that decouples the sender from the executor. In Granit, commands are Wolverine messages processed by automatically discovered handlers. ## Diagram [Section titled “Diagram”](#diagram) ``` sequenceDiagram participant E as Sender participant BUS as Wolverine Bus participant OB as Outbox participant H as Handler E->>BUS: Publish SendWebhookCommand BUS->>OB: Persist in Outbox Note over OB: Atomic transaction OB->>H: Dispatch post-commit H->>H: SendWebhookHandler.Handle() ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) | Command | File | Handler | | -------------------------- | ------------------------------------------------------------------------ | -------------------------- | | `SendWebhookCommand` | `src/Granit.Webhooks/Messages/SendWebhookCommand.cs` | `SendWebhookHandler` | | `RunMigrationBatchCommand` | `src/Granit.Persistence.Migrations/Messages/RunMigrationBatchCommand.cs` | `RunMigrationBatchHandler` | Commands are plain C# classes (serializable DTOs). Wolverine discovers handlers by naming convention (`Handle()` method). ## Rationale [Section titled “Rationale”](#rationale) Commands decouple the sender (HTTP handler) from the executor (background handler). Serialization via the Outbox guarantees delivery even in case of crash. Wolverine’s automatic retry handles transient failures. ## Usage example [Section titled “Usage example”](#usage-example) ```csharp // Define a command public sealed class SendInvoiceEmailCommand { public required Guid InvoiceId { get; init; } public required string RecipientEmail { get; init; } } // The handler is discovered automatically by Wolverine public static class SendInvoiceEmailHandler { public static async Task Handle( SendInvoiceEmailCommand command, IEmailService emailService, CancellationToken cancellationToken) { await emailService.SendInvoiceAsync(command.InvoiceId, command.RecipientEmail, ct); } } // Emission from an HTTP handler public static class CreateInvoiceHandler { public static IEnumerable Handle(CreateInvoiceCommand cmd, InvoiceDbContext db) { Invoice invoice = new() { /* ... */ }; db.Invoices.Add(invoice); // The command is persisted in the Outbox, not sent immediately yield return new SendInvoiceEmailCommand { InvoiceId = invoice.Id, RecipientEmail = cmd.Email }; } } ``` ## Further reading [Section titled “Further reading”](#further-reading) * [Command — refactoring.guru](https://refactoring.guru/design-patterns/command) # Composite > Progressive entity hierarchy with composable audit and compliance interfaces in Granit ## Definition [Section titled “Definition”](#definition) The Composite pattern allows treating individual objects and compositions of objects uniformly. In Granit, this pattern manifests in the auditable entity hierarchy where each level adds capabilities while remaining uniformly manipulable. ## Diagram [Section titled “Diagram”](#diagram) ``` classDiagram class Entity { +Id : Guid } class CreationAuditedEntity { +CreatedAt : DateTimeOffset +CreatedBy : string } class AuditedEntity { +ModifiedAt : DateTimeOffset? +ModifiedBy : string? } class FullAuditedEntity { +IsDeleted : bool +DeletedAt : DateTimeOffset? +DeletedBy : string? } class ISoftDeletable { <> } class IMultiTenant { +TenantId : Guid? } class IActive { +IsActive : bool } Entity <|-- CreationAuditedEntity CreationAuditedEntity <|-- AuditedEntity AuditedEntity <|-- FullAuditedEntity FullAuditedEntity ..|> ISoftDeletable ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) | Class | File | Added capabilities | | ----------------------- | ------------------------------------------------- | ------------------------------------------------------ | | `Entity` | `src/Granit.Core/Domain/Entity.cs` | `Id` (Guid) | | `CreationAuditedEntity` | `src/Granit.Core/Domain/CreationAuditedEntity.cs` | `CreatedAt`, `CreatedBy` | | `AuditedEntity` | `src/Granit.Core/Domain/AuditedEntity.cs` | `ModifiedAt`, `ModifiedBy` | | `FullAuditedEntity` | `src/Granit.Core/Domain/FullAuditedEntity.cs` | `IsDeleted`, `DeletedAt`, `DeletedBy` (ISoftDeletable) | | `ISoftDeletable` | `src/Granit.Core/Domain/ISoftDeletable.cs` | Soft delete marker | | `IMultiTenant` | `src/Granit.Core/Domain/IMultiTenant.cs` | `TenantId` isolation | | `IActive` | `src/Granit.Core/Domain/IActive.cs` | `IsActive` filtering | The marker interfaces (`ISoftDeletable`, `IMultiTenant`, `IActive`) are composable with the inheritance hierarchy. EF Core interceptors and query filters detect these interfaces via reflection and apply the appropriate behavior. ## Rationale [Section titled “Rationale”](#rationale) The progressive hierarchy allows choosing the required audit level per entity. A reference entity (postal code) only needs `Entity`. A medical entity under ISO 27001 needs `FullAuditedEntity` + `IMultiTenant`. The interceptors treat all entities uniformly. ## Usage example [Section titled “Usage example”](#usage-example) ```csharp // Simple entity -- identity only public sealed class Country : Entity { } // Entity with creation audit public sealed class Invitation : CreationAuditedEntity { } // Entity with full ISO 27001 audit + tenant isolation + GDPR soft delete public sealed class MedicalRecord : FullAuditedEntity, IMultiTenant { public Guid? TenantId { get; set; } public string Diagnosis { get; set; } = string.Empty; } // Interceptors automatically populate all audit fields ``` ## Further reading [Section titled “Further reading”](#further-reading) * [Composite — refactoring.guru](https://refactoring.guru/design-patterns/composite) # Copy-on-Write > How Granit uses immutable state with ImmutableDictionary to ensure thread-safe data filtering ## Definition [Section titled “Definition”](#definition) The Copy-on-Write pattern guarantees thread-safety by creating a new copy of the data structure on each modification, instead of mutating the existing one. The previous state remains intact, enabling simple restoration and eliminating data races. ## Diagram [Section titled “Diagram”](#diagram) ``` sequenceDiagram participant A as Async flow A participant AL as AsyncLocal participant B as Async flow B (child) A->>AL: State = {SoftDelete: true, Active: true} A->>B: Creates a child flow B->>B: Inherits parent state B->>AL: Disable ISoftDeletable AL->>AL: New dict = {SoftDelete: false, Active: true} Note over AL: The original is not modified A->>AL: Reads state AL-->>A: {SoftDelete: true, Active: true} Note over A: Not impacted by B's modification ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) | Component | File | Structure | | ------------ | --------------------------------------------- | --------------------------------------------- | | `DataFilter` | `src/Granit.Core/DataFiltering/DataFilter.cs` | `AsyncLocal>` | The `DataFilter` uses `ImmutableDictionary.SetItem()` which returns a **new** dictionary without mutating the original. Combined with `AsyncLocal`, this guarantees: 1. **Flow isolation**: a child flow that modifies filters does not disturb the parent flow 2. **Restoration**: the `IDisposable` scope keeps a reference to the old dictionary and restores it on `Dispose()` 3. **No locks**: `ImmutableDictionary` is inherently thread-safe ### Pitfall avoided — child flow mutation [Section titled “Pitfall avoided — child flow mutation”](#pitfall-avoided--child-flow-mutation) Without copy-on-write, an `AsyncLocal>` shared between parent and child could see the child’s mutations affect the parent. With `ImmutableDictionary`, `SetItem()` creates a new reference that does not propagate back to the parent. ## Rationale [Section titled “Rationale”](#rationale) Data filtering must be isolated per request scope. If a middleware temporarily disables soft delete for an admin operation, that change must not leak into other concurrent requests or child `Task.Run()` flows. ## Usage example [Section titled “Usage example”](#usage-example) ```csharp // Copy-on-write is transparent -- the API remains simple using (dataFilter.Disable()) { // New ImmutableDictionary created: {SoftDelete: false} // The original remains intact await Task.Run(async () => { // This child flow inherits {SoftDelete: false} // But if the child calls Enable(), // it creates a NEW dict for the child without touching the parent }); } // Dispose() restores the original ImmutableDictionary ``` # CQRS > Command Query Responsibility Segregation with enforced Reader/Writer interface separation ## Definition [Section titled “Definition”](#definition) CQRS separates **read** (Query) and **write** (Command) operations into distinct interfaces. Each consumer injects only the interface matching its intent, making dependencies explicit and code easier to audit. ## Diagram [Section titled “Diagram”](#diagram) ``` flowchart LR subgraph Read["Query side"] EP_R["Endpoint / Handler
read-only"] Reader["IXxxReader"] DB_R[(Database
AsNoTracking)] end subgraph Write["Command side"] EP_W["Endpoint / Handler
write"] Writer["IXxxWriter"] DB_W[(Database
SaveChangesAsync)] end EP_R --> Reader --> DB_R EP_W --> Writer --> DB_W style Read fill:#e8f4fd,stroke:#1a73e8 style Write fill:#fef3e0,stroke:#e8a317 ``` ``` classDiagram class IBlobDescriptorReader { +FindAsync(blobId, ct) BlobDescriptor +FindOrphanedAsync(cutoff, batchSize, ct) IReadOnlyList } class IBlobDescriptorWriter { +SaveAsync(descriptor, ct) Task +UpdateAsync(descriptor, ct) Task } class IBlobDescriptorStore { } IBlobDescriptorStore --|> IBlobDescriptorReader IBlobDescriptorStore --|> IBlobDescriptorWriter class DefaultBlobStorage { -reader : IBlobDescriptorReader -writer : IBlobDescriptorWriter } DefaultBlobStorage ..> IBlobDescriptorReader : injects DefaultBlobStorage ..> IBlobDescriptorWriter : injects ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) ### Reader / Writer / Store convention [Section titled “Reader / Writer / Store convention”](#reader--writer--store-convention) Each data module exposes **three interfaces**: | Interface | Role | Example | | ------------ | ------------------------------------------- | --------------------------------------------------------------------- | | `IXxxReader` | Read-only operations | `IBlobDescriptorReader`, `IFeatureStoreReader` | | `IXxxWriter` | Write-only operations | `IBlobDescriptorWriter`, `IFeatureStoreWriter` | | `IXxxStore` | Reader + Writer union (for DI registration) | `IBlobDescriptorStore : IBlobDescriptorReader, IBlobDescriptorWriter` | **Strict rule**: the combined `Store` exists for DI registration only. Constructors of handlers, endpoints, and services **must** inject `IXxxReader` or `IXxxWriter` separately, never the combined `Store`. ### Architectural enforcement [Section titled “Architectural enforcement”](#architectural-enforcement) **ArchUnitNET tests** (`tests/Granit.ArchitectureTests/CqrsConventionTests.cs`): ```csharp [Fact] public void Reader_interfaces_should_end_with_Reader() => NamingConventionRules.ReaderInterfacesShouldEndWithReader(Architecture); [Fact] public void Writer_interfaces_should_end_with_Writer() => NamingConventionRules.WriterInterfacesShouldEndWithWriter(Architecture); ``` ### Reader/Writer inventory (excerpt) [Section titled “Reader/Writer inventory (excerpt)”](#readerwriter-inventory-excerpt) | Module | Reader | Writer | | -------------- | --------------------------------------- | --------------------------------------- | | BlobStorage | `IBlobDescriptorReader` | `IBlobDescriptorWriter` | | Features | `IFeatureStoreReader` | `IFeatureStoreWriter` | | Settings | `ISettingStoreReader` | `ISettingStoreWriter` | | BackgroundJobs | `IBackgroundJobReader` | `IBackgroundJobWriter` | | Webhooks | `IWebhookSubscriptionReader` | `IWebhookSubscriptionWriter` | | Notifications | `IUserNotificationReader` | `IUserNotificationWriter` | | Authorization | `IPermissionManagerReader` | `IPermissionManagerWriter` | | Localization | `ILocalizationOverrideStoreReader` | `ILocalizationOverrideStoreWriter` | | Templating | `IDocumentTemplateStoreReader` | `IDocumentTemplateStoreWriter` | | Timeline | `ITimelineReader` | `ITimelineWriter` | | DataExchange | `IImportJobReader` / `IExportJobReader` | `IImportJobWriter` / `IExportJobWriter` | | ReferenceData | `IReferenceDataStoreReader` | `IReferenceDataStoreWriter` | ### Reference files [Section titled “Reference files”](#reference-files) | File | Role | | ------------------------------------------------------------------------------- | ---------------------------------------------- | | `src/Granit.BlobStorage/IBlobDescriptorReader.cs` | Canonical Reader interface | | `src/Granit.BlobStorage/IBlobDescriptorWriter.cs` | Canonical Writer interface | | `src/Granit.BlobStorage/IBlobDescriptorStore.cs` | Combined Store interface (DI only) | | `src/Granit.BlobStorage/Internal/DefaultBlobStorage.cs` | Separate Reader + Writer injection | | `src/Granit.BackgroundJobs.Endpoints/Endpoints/BackgroundJobsReadEndpoints.cs` | Endpoint injecting only `IBackgroundJobReader` | | `src/Granit.BackgroundJobs.Endpoints/Endpoints/BackgroundJobsWriteEndpoints.cs` | Endpoint injecting only `IBackgroundJobWriter` | | `tests/Granit.ArchitectureTests/CqrsConventionTests.cs` | CQRS convention tests | ## Rationale [Section titled “Rationale”](#rationale) | Problem | CQRS solution | | -------------------------------------------------- | ------------------------------------------------------------- | | A service injects a full store when it only reads | Injecting the Reader alone makes the intent explicit | | ISO 27001 audit: who can write what? | The DI graph immediately shows components with write access | | GDPR: no hard-delete on readers | Writer interfaces do not expose a physical `Delete` method | | Refactoring: merging Reader/Writer for convenience | ArchUnitNET tests block the regression | | Wolverine handlers: clear responsibility | Command handlers inject the Writer, query handlers the Reader | ## Usage example [Section titled “Usage example”](#usage-example) ```csharp // --- Read endpoint -- injects ONLY the Reader --- private static async Task>> GetAllJobsAsync( IBackgroundJobReader reader, [FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken cancellationToken = default) { IReadOnlyList all = await reader .GetAllAsync(cancellationToken).ConfigureAwait(false); return TypedResults.Ok(new PagedResult(/* ... */)); } // --- Write endpoint -- injects ONLY the Writer --- private static async Task> PauseJobAsync( string name, IBackgroundJobWriter writer, CancellationToken cancellationToken) { await writer.PauseAsync(name, cancellationToken).ConfigureAwait(false); return TypedResults.NoContent(); } // --- Wolverine command handler -- Writer only --- public static async Task Handle( SendWebhookCommand command, IWebhookDeliveryWriter deliveryWriter, CancellationToken cancellationToken) { await deliveryWriter.RecordSuccessAsync(command, /* ... */, cancellationToken) .ConfigureAwait(false); } ``` ## Further reading [Section titled “Further reading”](#further-reading) * [CQRS pattern — Microsoft Cloud Design Patterns](https://learn.microsoft.com/en-us/azure/architecture/patterns/cqrs) * [CQRS — Martin Fowler](https://martinfowler.com/bliki/CQRS.html) # Data Filtering > How Granit automatically applies global query filters based on marker interfaces ## Definition [Section titled “Definition”](#definition) The Data Filtering pattern automatically applies global filters to EF Core queries based on marker interfaces implemented by entities. Filters are enabled by default and can be temporarily disabled via an `IDisposable` scope. Granit supports three filters: * `ISoftDeletable` — `WHERE IsDeleted = false` * `IActive` — `WHERE IsActive = true` * `IMultiTenant` — `WHERE TenantId = @currentTenantId` ## Diagram [Section titled “Diagram”](#diagram) ``` flowchart TD Q[EF Core query] --> FB{Active filters?} FB -->|ISoftDeletable| F1["WHERE IsDeleted = false
(or bypass if disabled)"] FB -->|IActive| F2["WHERE IsActive = true
(or bypass if disabled)"] FB -->|IMultiTenant| F3["WHERE TenantId = @tid
(or bypass if disabled)"] F1 --> COMB["Combined expression
AND"] F2 --> COMB F3 --> COMB COMB --> SQL[Final SQL] subgraph DataFilter DF["AsyncLocal of ImmutableDictionary"] EN[Enable/Disable scopes] DF --> EN end subgraph FilterProxy FP["Boolean properties
for EF Core"] end DataFilter --> FB FilterProxy --> FB ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) | Component | File | Role | | -------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------ | | `IDataFilter` | `src/Granit.Core/DataFiltering/IDataFilter.cs` | Interface: `IsEnabled()`, `Disable()`, `Enable()` | | `DataFilter` | `src/Granit.Core/DataFiltering/DataFilter.cs` | Implementation using `AsyncLocal>` | | `FilterProxy` | `src/Granit.Persistence/Extensions/ModelBuilderExtensions.cs` | Proxy exposing properties for EF Core | | `ApplyGranitConventions()` | `src/Granit.Persistence/Extensions/ModelBuilderExtensions.cs` | Builds Expression Trees for `HasQueryFilter()` | ### Dynamic filter construction [Section titled “Dynamic filter construction”](#dynamic-filter-construction) `ApplyGranitConventions()` uses **Expression Trees** to build a single filter per entity combining all applicable filters: ```csharp // Pseudo-code of the generated filter for a FullAuditedEntity + IMultiTenant entity => (!proxy.SoftDeleteEnabled || !entity.IsDeleted) && (!proxy.MultiTenantEnabled || entity.TenantId == proxy.CurrentTenantId) ``` The `FilterProxy` is essential: EF Core cannot call methods inside a query filter. The proxy exposes simple properties that EF Core translates into SQL parameters. ### Thread-safe state via AsyncLocal + ImmutableDictionary [Section titled “Thread-safe state via AsyncLocal + ImmutableDictionary”](#thread-safe-state-via-asynclocal--immutabledictionary) The `DataFilter` uses `AsyncLocal>`: * **AsyncLocal**: state is isolated per `async/await` flow * **ImmutableDictionary**: `SetItem()` creates a new dictionary (copy-on-write) * **IDisposable scope**: `Disable()` returns a scope that restores the previous state on `Dispose()` ## Rationale [Section titled “Rationale”](#rationale) | Problem | Solution | | ------------------------------------------------------------- | ---------------------------------------------------------------- | | Forgetting a `WHERE IsDeleted = false` in a query | Filter is automatic, applied to all queries | | Multi-tenant isolation: a query leaking another tenant’s data | `WHERE TenantId = @tid` automatic on every `IMultiTenant` entity | | Admin need to view deleted data | `dataFilter.Disable()` in a limited scope | | Thread safety in async scenarios | `AsyncLocal` + `ImmutableDictionary` (copy-on-write) | ## Usage example [Section titled “Usage example”](#usage-example) ```csharp // Filters are automatic -- nothing to do List activePatients = await db.Patients.ToListAsync(ct); // SQL: SELECT ... WHERE IsDeleted = 0 AND TenantId = @tid // Temporarily disable the soft delete filter using (dataFilter.Disable()) { List allPatients = await db.Patients.ToListAsync(ct); // SQL: SELECT ... WHERE TenantId = @tid (no IsDeleted filter) } // Filter is automatically re-enabled here ``` ## Further reading [Section titled “Further reading”](#further-reading) * [Query Object — Martin Fowler (PoEAA)](https://martinfowler.com/eaaCatalog/queryObject.html) # Decorator > Layered cache services with serialization, encryption, and anti-stampede in Granit ## Definition [Section titled “Definition”](#definition) The Decorator pattern dynamically adds responsibilities to an object without modifying its class. Each decorator wraps the original object and enriches its behavior (serialization, encryption, caching, anti-stampede protection). ## Diagram [Section titled “Diagram”](#diagram) ``` classDiagram class IDistributedCache { +GetAsync() +SetAsync() } class DistributedCacheService { -cache : IDistributedCache -encryptor : ICacheValueEncryptor -semaphore : SemaphoreSlim +GetOrAddAsync() } class ILocalizationOverrideStore { +GetOverridesAsync() +SetOverrideAsync() } class CachedLocalizationOverrideStore { -inner : ILocalizationOverrideStore -memoryCache : IMemoryCache +GetOverridesAsync() +SetOverrideAsync() } DistributedCacheService --> IDistributedCache : decorates CachedLocalizationOverrideStore --> ILocalizationOverrideStore : decorates ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) | Decorator | File | Target | Added responsibilities | | --------------------------------- | ------------------------------------------------------------ | ---------------------------- | ----------------------------------------------------------------------------------------- | | `DistributedCacheService` | `src/Granit.Caching/DistributedCacheService.cs` | `IDistributedCache` | JSON serialization, `ICacheValueEncryptor` encryption, double-check locking anti-stampede | | `CachedLocalizationOverrideStore` | `src/Granit.Localization/CachedLocalizationOverrideStore.cs` | `ILocalizationOverrideStore` | In-memory cache with per-tenant invalidation | **Custom variant — Conditional encryption**: `DistributedCacheService` applies AES-256-CBC encryption only if the target type carries the `[CacheEncrypted]` attribute or if the configuration requires it. ## Rationale [Section titled “Rationale”](#rationale) Separating concerns (serialization, encryption, anti-stampede) from cache logic allows testing and configuring them independently. The localization decorator avoids hitting the database on every translation resolution. ## Usage example [Section titled “Usage example”](#usage-example) ```csharp // The consumer uses ICacheService -- the decorator is transparent ICacheService cache = serviceProvider .GetRequiredService>(); PatientDto patient = await cache.GetOrAddAsync( $"patient:{patientId}", async ct => await db.Patients.FindAsync([patientId], ct), cancellationToken); // Behind the scenes: // 1. Check IDistributedCache (Redis) // 2. If miss -> SemaphoreSlim (anti-stampede) // 3. Double-check after lock // 4. Execute the factory // 5. Serialize to JSON -> encrypt (if [CacheEncrypted]) -> store in Redis ``` ## Further reading [Section titled “Further reading”](#further-reading) * [Decorator — refactoring.guru](https://refactoring.guru/design-patterns/decorator) # Double-Check Locking > How Granit prevents cache stampedes with double-check locking in the distributed cache service ## Definition [Section titled “Definition”](#definition) The Double-Check Locking pattern optimizes concurrent access to a shared resource by checking the condition **before** and **after** acquiring a lock. The first check (without lock) serves as a fast-path for the nominal case (cache hit). The second check (after lock) protects against races. ## Diagram [Section titled “Diagram”](#diagram) ``` flowchart TD REQ[GetOrAddAsync] --> C1{Check 1
without lock} C1 -->|hit| RET[Return value] C1 -->|miss| ACQ[Acquire SemaphoreSlim] ACQ --> C2{Check 2
after lock} C2 -->|hit| REL1[Release lock] --> RET C2 -->|miss| FAC[Execute factory] FAC --> SET[Store in cache] SET --> REL2[Release lock] --> RET style C1 fill:#2d5a27,color:#fff style ACQ fill:#ff6b6b,color:#fff style C2 fill:#4a9eff,color:#fff ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) | Component | File | Lines | | ----------------------------------------- | ----------------------------------------------- | ----- | | `DistributedCacheService.GetOrAddAsync()` | `src/Granit.Caching/DistributedCacheService.cs` | 74-92 | ### Flow [Section titled “Flow”](#flow) 1. **Check 1** (line 74): cache read without lock — fast-path 2. **Acquire** (line 78): `SemaphoreSlim.WaitAsync(cancellationToken)` 3. **Check 2** (line 82): re-read cache after lock 4. **Factory** (line 86): execute factory if still a miss 5. **Set** (line 89): store in cache 6. **Release** (line 92): `SemaphoreSlim.Release()` in a `finally` ### Anti-stampede [Section titled “Anti-stampede”](#anti-stampede) If 100 simultaneous requests have a cache miss: * All 100 pass Check 1 (miss) * 1 acquires the lock, 99 wait * The first executes the factory and populates the cache * The 99 pass Check 2 — cache hit, no factory call Result: **1 single DB query** instead of 100. ## Rationale [Section titled “Rationale”](#rationale) Double-check locking is essential for feature resolution in a high-concurrency environment. Without protection, a simultaneous cache miss (cache expiration, restart) could overwhelm the database. ## Usage example [Section titled “Usage example”](#usage-example) ```csharp // Double-check locking is internal -- the API is simple ICacheService cache = provider.GetRequiredService>(); // 100 simultaneous calls with the same cache key: // -> 1 DB query (the first one) // -> 99 responses from cache (after the lock) PatientDto patient = await cache.GetOrAddAsync( $"patient:{patientId}", async ct => await db.Patients.FindAsync([patientId], ct), cancellationToken); ``` # Event-Driven Architecture > Decoupled communication through domain events and integration events with transactional guarantees ## Definition [Section titled “Definition”](#definition) Event-driven architecture decouples system components through the publication and consumption of events. Instead of calling a service directly, a component publishes an event; interested parties react to it asynchronously. Granit distinguishes two categories of events with fundamentally different guarantees: * **IDomainEvent**: in-process, synchronous, same transaction. Never crosses the Outbox. Naming convention: `XxxOccurred`. * **IIntegrationEvent**: cross-module, durable via Wolverine Outbox, dispatched only after the transaction commits. Naming convention: `XxxEvent`. Flat DTOs only (never EF Core entities). ## Diagram [Section titled “Diagram”](#diagram) ``` sequenceDiagram participant H as Handler participant DB as DbContext participant LQ as Local Queue participant DH as Domain Handler participant OB as Outbox participant TX as Transport participant IH as Integration Handler Note over H,DH: IDomainEvent -- same transaction H->>DB: Modify entity H->>LQ: Publish PatientDischargedOccurred LQ->>DH: Handle (synchronous, same tx) DH->>DB: Read/write in the same tx H->>DB: SaveChangesAsync() -- single commit Note over H,IH: IIntegrationEvent -- via Outbox H->>DB: Modify entity H->>OB: Publish BedReleasedEvent H->>DB: SaveChangesAsync() -- atomic commit (data + Outbox) OB->>TX: Post-commit dispatch TX->>IH: Guaranteed delivery (retry, DLQ) ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) ### Marker interfaces [Section titled “Marker interfaces”](#marker-interfaces) | Interface | File | Routing | | ------------------- | --------------------------------------------- | ------------------------------------------------------- | | `IDomainEvent` | `src/Granit.Core/Events/IDomainEvent.cs` | Local queue only — never the Outbox | | `IIntegrationEvent` | `src/Granit.Core/Events/IIntegrationEvent.cs` | Configured transport (PostgreSQL, RabbitMQ…) via Outbox | ### Wolverine configuration [Section titled “Wolverine configuration”](#wolverine-configuration) In `src/Granit.Wolverine/Extensions/WolverineHostApplicationBuilderExtensions.cs`: ```csharp // Lines 74-77: forces domain events to local opts.PublishMessage() .ToLocalQueue("domain-events"); ``` ### Existing handlers [Section titled “Existing handlers”](#existing-handlers) | Handler | Event | Type | File | | --------------------------------- | -------------------------- | ----------- | -------------------------------------------------------------- | | `FeatureCacheInvalidationHandler` | `FeatureValueChangedEvent` | Domain | `src/Granit.Features/Cache/FeatureCacheInvalidationHandler.cs` | | `WebhookFanoutHandler` | `WebhookTrigger` | Integration | `src/Granit.Webhooks/Handlers/WebhookFanoutHandler.cs` | ### Wolverine sidecar pattern [Section titled “Wolverine sidecar pattern”](#wolverine-sidecar-pattern) Wolverine handlers can return events via `yield return` or by returning an `IEnumerable`. Wolverine dispatches them automatically: `IDomainEvent` goes to local, `IIntegrationEvent` goes to the Outbox. ## Rationale [Section titled “Rationale”](#rationale) | Problem | Solution | | --------------------------------------------------------------------------------- | ----------------------------------------------------------------- | | Need to react to a change without coupling modules | Handlers subscribe to events without knowing the publisher | | Transactional guarantee: “if the patient is discharged, the bed must be released” | `IDomainEvent` in the same transaction ensures atomicity | | Delivery guarantee: “the webhook must be sent even if the server restarts” | `IIntegrationEvent` via Outbox persists the event before dispatch | | Preventing event loss on rollback | The Outbox is dispatched only after a successful commit | | Serialization: EF Core entities must not cross service boundaries | `IIntegrationEvent` enforces flat serializable DTOs | ## Usage example [Section titled “Usage example”](#usage-example) ```csharp // 1. Define a domain event (in-process, same transaction) public sealed class PatientDischargedOccurred : IDomainEvent { public required Guid PatientId { get; init; } public required Guid BedId { get; init; } } // 2. Define an integration event (durable, cross-module) public sealed class BedReleasedEvent : IIntegrationEvent { public required Guid BedId { get; init; } public required Guid WardId { get; init; } public required DateTimeOffset ReleasedAt { get; init; } } // 3. Handler publishing both types public static class DischargePatientHandler { public static IEnumerable Handle( DischargePatientCommand command, PatientDbContext db) { Patient patient = db.Patients.Find(command.PatientId) ?? throw new EntityNotFoundException(typeof(Patient), command.PatientId); patient.Discharge(); // Domain event -- handled in the same transaction yield return new PatientDischargedOccurred { PatientId = patient.Id, BedId = patient.BedId }; // Integration event -- persisted in the Outbox, dispatched after commit yield return new BedReleasedEvent { BedId = patient.BedId, WardId = patient.WardId, ReleasedAt = DateTimeOffset.UtcNow }; } } ``` ## Further reading [Section titled “Further reading”](#further-reading) * [Event-Driven Architecture Style — Microsoft Azure Architecture Center](https://learn.microsoft.com/en-us/azure/architecture/guide/architecture-styles/event-driven) * [Publisher-Subscriber — Microsoft Cloud Design Patterns](https://learn.microsoft.com/en-us/azure/architecture/patterns/publisher-subscriber) # Expression Trees > How Granit dynamically builds EF Core query filters using expression trees for marker interfaces ## Definition [Section titled “Definition”](#definition) Expression Trees allow building queries or filters at runtime as a syntax tree, instead of writing them statically in source code. EF Core translates these trees into SQL. In Granit, `ApplyGranitConventions()` dynamically builds EF Core query filters for each entity by combining `ISoftDeletable`, `IActive`, and `IMultiTenant` filters into a single expression. ## Diagram [Section titled “Diagram”](#diagram) ``` flowchart TD AGC["ApplyGranitConventions()"] --> SCAN[Scan DbContext entities] SCAN --> CHECK{Which interfaces implemented?} CHECK -->|ISoftDeletable| E1["Expression: !e.IsDeleted
or !proxy.SoftDeleteEnabled"] CHECK -->|IActive| E2["Expression: e.IsActive
or !proxy.ActiveEnabled"] CHECK -->|IMultiTenant| E3["Expression: e.TenantId == proxy.CurrentTenantId
or !proxy.MultiTenantEnabled"] E1 --> COMBINE["Expression.AndAlso()
Combine all conditions"] E2 --> COMBINE E3 --> COMBINE COMBINE --> LAMBDA["Expression.Lambda of Func T bool"] LAMBDA --> HQF["entity.HasQueryFilter(lambda)"] ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) | Component | File | Lines | | -------------------------- | ------------------------------------------------------------- | ------- | | `ApplyGranitConventions()` | `src/Granit.Persistence/Extensions/ModelBuilderExtensions.cs` | 54-126 | | `FilterProxy` | `src/Granit.Persistence/Extensions/ModelBuilderExtensions.cs` | 133-140 | ### Why a FilterProxy? [Section titled “Why a FilterProxy?”](#why-a-filterproxy) EF Core cannot translate arbitrary method calls (like `dataFilter.IsEnabled()`) in a query filter. The `FilterProxy` exposes **simple properties** that EF Core extracts as SQL parameters: ```csharp // FilterProxy exposes properties that EF Core understands internal sealed class FilterProxy(IDataFilter? dataFilter, ICurrentTenant? tenant) { public bool SoftDeleteEnabled => dataFilter?.IsEnabled() ?? true; public bool ActiveEnabled => dataFilter?.IsEnabled() ?? true; public bool MultiTenantEnabled => dataFilter?.IsEnabled() ?? true; public Guid? CurrentTenantId => tenant?.Id; } ``` ### Why a single HasQueryFilter? [Section titled “Why a single HasQueryFilter?”](#why-a-single-hasqueryfilter) EF Core (versions before 10) overwrites previous query filters if `HasQueryFilter()` is called multiple times on the same entity. The combined expression via `AndAlso` solves this problem in a single call. ## Rationale [Section titled “Rationale”](#rationale) Dynamic construction handles all combinations of interfaces automatically (an entity can implement 0, 1, 2, or 3 marker interfaces) without writing specific code for each combination (2^3 = 8 cases). ## Usage example [Section titled “Usage example”](#usage-example) ```csharp // The application calls a single line in OnModelCreating protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.ApplyGranitConventions(serviceProvider); // -> Dynamically builds query filters for all entities } // The filter is automatic and transparent List patients = await db.Patients.ToListAsync(ct); // Generated SQL: // SELECT * FROM Patients // WHERE (@SoftDeleteEnabled = 0 OR IsDeleted = 0) // AND (@MultiTenantEnabled = 0 OR TenantId = @CurrentTenantId) ``` ## Further reading [Section titled “Further reading”](#further-reading) * [Expression Trees — Microsoft .NET Documentation](https://learn.microsoft.com/en-us/dotnet/csharp/advanced-topics/expression-trees/) # Facade > Simplified entry points for blob storage orchestration and exception handling in Granit ## Definition [Section titled “Definition”](#definition) The Facade pattern provides a simplified interface to a complex subsystem. It hides the complexity of interactions between multiple components behind a single, cohesive entry point. ## Diagram [Section titled “Diagram”](#diagram) ``` classDiagram class IBlobStorage { +InitiateUploadAsync() +CreateDownloadUrlAsync() +DeleteAsync() } class DefaultBlobStorage { -currentTenant : ICurrentTenant -store : IBlobDescriptorStore -keyStrategy : IBlobKeyStrategy -storageClient : IBlobStorageClient -urlGenerator : IBlobPresignedUrlGenerator -validators : IBlobValidator[] -clock : IClock } class GranitExceptionHandler { -mappers : IExceptionStatusCodeMapper[] -logger : ILogger } DefaultBlobStorage ..|> IBlobStorage DefaultBlobStorage --> IBlobDescriptorStore DefaultBlobStorage --> IBlobKeyStrategy DefaultBlobStorage --> IBlobStorageClient DefaultBlobStorage --> IBlobPresignedUrlGenerator DefaultBlobStorage --> IBlobValidator ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) | Facade | File | Orchestrated sub-components | | ------------------------ | -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `DefaultBlobStorage` | `src/Granit.BlobStorage/Internal/DefaultBlobStorage.cs` | `ICurrentTenant`, `IBlobDescriptorStore`, `IBlobKeyStrategy`, `IBlobStorageClient`, `IBlobPresignedUrlGenerator`, `IBlobValidator[]`, `IClock` | | `GranitExceptionHandler` | `src/Granit.ExceptionHandling/GranitExceptionHandler.cs` | `IExceptionStatusCodeMapper[]`, `ILogger`, `ExceptionHandlingOptions` | ## Rationale [Section titled “Rationale”](#rationale) Without the `DefaultBlobStorage` facade, application code would have to manually orchestrate tenant resolution, S3 key generation, descriptor creation, presigned URL generation, and validation — on every operation. The facade encapsulates this complexity behind 3 public methods. `GranitExceptionHandler` centralizes exception-to-`ProblemDetails` conversion (RFC 7807), hiding the mapper chain and ISO 27001 rules (masking internal details in production). ## Usage example [Section titled “Usage example”](#usage-example) ```csharp // The caller interacts with a simple API -- complexity is hidden IBlobStorage blobStorage = serviceProvider.GetRequiredService(); // Behind this call: tenant resolution, descriptor creation, // S3 key generation, presigned URL, database persistence PresignedUploadTicket ticket = await blobStorage.InitiateUploadAsync( "avatars", new BlobUploadRequest("photo.jpg", "image/jpeg", MaxAllowedBytes: 5_000_000), cancellationToken); ``` ## Further reading [Section titled “Further reading”](#further-reading) * [Facade — refactoring.guru](https://refactoring.guru/design-patterns/facade) # Factory Method > Runtime creation of Vault clients and tenant-isolated DbContexts in Granit ## Definition [Section titled “Definition”](#definition) The Factory Method pattern delegates object creation to subclasses or specialized methods, allowing the type of created object to vary without modifying calling code. The caller works with the interface; the factory selects the concrete implementation. ## Diagram [Section titled “Diagram”](#diagram) ``` classDiagram class VaultClientFactory { +Create() IVaultClient } class IVaultClient { <> } class KubernetesAuthClient class TokenAuthClient VaultClientFactory ..> IVaultClient : creates IVaultClient <|.. KubernetesAuthClient IVaultClient <|.. TokenAuthClient class ITenantIsolationStrategyProvider { +Create() DbContext } class SharedDatabaseDbContextFactory class TenantPerSchemaDbContextFactory class TenantPerDatabaseDbContextFactory ITenantIsolationStrategyProvider <|.. SharedDatabaseDbContextFactory ITenantIsolationStrategyProvider <|.. TenantPerSchemaDbContextFactory ITenantIsolationStrategyProvider <|.. TenantPerDatabaseDbContextFactory ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) | Factory | File | Selection | | ----------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------ | | `VaultClientFactory` | `src/Granit.Vault/Services/VaultClientFactory.cs` | Switch expression on `AuthMethod` (Kubernetes / Token) | | `SharedDatabaseDbContextFactory` | `src/Granit.Persistence/MultiTenancy/SharedDatabaseDbContextFactory.cs` | SharedDatabase strategy | | `TenantPerSchemaDbContextFactory` | `src/Granit.Persistence/MultiTenancy/TenantPerSchemaDbContextFactory.cs` | SchemaPerTenant strategy | | `TenantPerDatabaseDbContextFactory` | `src/Granit.Persistence/MultiTenancy/TenantPerDatabaseDbContextFactory.cs` | DatabasePerTenant strategy | **Custom variant**: the persistence factories combine Factory Method + Strategy — the strategy is selected at configuration time, and the factory creates the appropriate `DbContext` per request. ## Rationale [Section titled “Rationale”](#rationale) The choice of Vault authentication method (Kubernetes in production, Token in development) and tenant isolation strategy must be resolved at runtime without `if/else` in application code. ## Usage example [Section titled “Usage example”](#usage-example) ```csharp // The factory is resolved via DI -- calling code is unaware of the implementation IVaultClient client = vaultClientFactory.Create(); SecretData secret = await client.V1.Secrets.KeyValue.V2 .ReadSecretAsync("app/database", cancellationToken: ct); ``` ## Further reading [Section titled “Further reading”](#further-reading) * [Factory Method — refactoring.guru](https://refactoring.guru/design-patterns/factory-method) # Fan-Out > One trigger to N independent commands via Wolverine cascading messages and Outbox ## Definition [Section titled “Definition”](#definition) **Fan-Out** (or Scatter) transforms a single message into N independent messages, each processed in parallel. In a messaging context, the handler receives a trigger and returns a collection of commands — the bus publishes each command individually within the same Outbox transaction. Granit uses this pattern in notifications (one trigger to N deliveries per recipient x channel) and webhooks (one event to N sends per active subscription). ## Diagram [Section titled “Diagram”](#diagram) ``` flowchart LR T[Trigger] --> H[FanoutHandler] H --> C1[Command 1] H --> C2[Command 2] H --> C3[Command N] C1 --> W1[Worker 1] C2 --> W2[Worker 2] C3 --> W3[Worker N] ``` ``` sequenceDiagram participant App participant Fan as NotificationFanoutHandler participant Sub as SubscriptionReader participant Bus as Wolverine Outbox App->>Bus: NotificationTrigger Bus->>Fan: HandleAsync(trigger) Fan->>Sub: Resolve recipients Sub-->>Fan: [User A, User B, User C] Fan-->>Bus: IEnumerable DeliverNotificationCommand Bus->>Bus: Publish Command A (Email) Bus->>Bus: Publish Command B (Push) Bus->>Bus: Publish Command C (Email + SMS) ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) Granit implements Fan-Out in 2 distinct modules, leveraging the Wolverine `Task>` convention: each returned element is published as an independent message within the same Outbox transaction. ### 1. NotificationFanoutHandler — multi-channel notifications [Section titled “1. NotificationFanoutHandler — multi-channel notifications”](#1-notificationfanouthandler--multi-channel-notifications) Transforms a `NotificationTrigger` into N `DeliverNotificationCommand` (one per recipient x preferred channel). | Element | Detail | | ---------- | -------------------------------------------------------- | | Class | `NotificationFanoutHandler` | | Package | `Granit.Notifications` | | Input | `NotificationTrigger` | | Output | `IEnumerable` | | Resolution | Explicit > Followers > Subscribers (decreasing priority) | ```csharp public async Task> HandleAsync( NotificationTrigger trigger, CancellationToken cancellationToken) { // 1. Resolve recipients (explicit > followers > subscribers) // 2. Load channel preferences per user // 3. Create a DeliverNotificationCommand per recipient x channel // Return empty if no recipients -- no exception } ``` **Channel resolution logic**: each recipient can have preferences (opt-out by channel). The `NotificationDefinition` provides default channels. The handler filters out disabled channels before creating commands. ### 2. WebhookFanoutHandler — webhooks per subscription [Section titled “2. WebhookFanoutHandler — webhooks per subscription”](#2-webhookfanouthandler--webhooks-per-subscription) Transforms a `WebhookTrigger` into N `SendWebhookCommand` (one per active subscription for the event type). | Element | Detail | | --------- | ------------------------------------ | | Class | `WebhookFanoutHandler` | | Package | `Granit.Webhooks` | | Input | `WebhookTrigger` | | Output | `IEnumerable` | | Filtering | Active subscriptions for `EventType` | ```csharp public async Task> HandleAsync( WebhookTrigger trigger, CancellationToken cancellationToken) { // 1. Query active subscriptions for trigger.EventType // 2. Create a standardized WebhookEnvelope (metadata + payload) // 3. Create a SendWebhookCommand per subscription // Return empty if no subscriptions -- no exception } ``` Each command receives a distinct `DeliveryId` (separate from the shared `EventId`), enabling individual tracking and isolated retry. ### Common architectural properties [Section titled “Common architectural properties”](#common-architectural-properties) | Property | Detail | | ----------------------- | --------------------------------------------------------------------- | | Wolverine convention | `Task>` — automatic cascade into the Outbox | | Transactional guarantee | All commands published in the same Outbox transaction | | Empty case | Returns `Enumerable.Empty()` — no exception | | Idempotency | Unique `DeliveryId` per command for audit and retry | | Multi-tenancy | Ambient `ICurrentTenant` context with fallback to embedded `TenantId` | | Observability | OpenTelemetry Activity with fan-out cardinality | ### Reference files [Section titled “Reference files”](#reference-files) | File | Role | | ----------------------------------------------------------------- | -------------------- | | `src/Granit.Notifications/Handlers/NotificationFanoutHandler.cs` | Notification fan-out | | `src/Granit.Notifications/Messages/NotificationTrigger.cs` | Trigger message | | `src/Granit.Notifications/Messages/DeliverNotificationCommand.cs` | Delivery command | | `src/Granit.Webhooks/Handlers/WebhookFanoutHandler.cs` | Webhook fan-out | | `src/Granit.Webhooks/Messages/WebhookTrigger.cs` | Trigger message | | `src/Granit.Webhooks/Messages/SendWebhookCommand.cs` | Send command | ## Rationale [Section titled “Rationale”](#rationale) | Problem | Solution | | ------------------------------------------------- | ------------------------------------------------------------ | | Notification to 100 recipients blocks the handler | Fan-out into 100 independent commands, processed in parallel | | One webhook failure must not block the others | Each `SendWebhookCommand` has its own isolated retry | | Message loss if crash during fan-out | Wolverine Outbox — atomic commit of all commands | | Recipient opts out of a channel | Filtering by preferences before creating commands | | Audit trail per delivery | Unique `DeliveryId` per command (distinct from `EventId`) | ## Usage example [Section titled “Usage example”](#usage-example) ```csharp // --- Trigger a notification (automatic fan-out) --- await messageBus.PublishAsync( new NotificationTrigger( NotificationId: guidGenerator.Create(), NotificationTypeName: "appointment.reminder", Data: JsonSerializer.SerializeToElement(new { PatientName = "Dupont" }), RecipientUserIds: [doctorId, secretaryId]), cancellationToken).ConfigureAwait(false); // The NotificationFanoutHandler resolves preferred channels for each recipient // and publishes a DeliverNotificationCommand per recipient x channel. // --- Trigger a webhook (automatic fan-out) --- await messageBus.PublishAsync( new WebhookTrigger( EventId: guidGenerator.Create(), EventType: "invoice.paid", Payload: JsonSerializer.SerializeToElement(invoiceDto), OccurredAt: timeProvider.GetUtcNow()), cancellationToken).ConfigureAwait(false); // The WebhookFanoutHandler creates a SendWebhookCommand per active subscription // for "invoice.paid". Each delivery is signed and sent independently. ``` ## Further reading [Section titled “Further reading”](#further-reading) * [Enterprise Integration Patterns — Splitter](https://www.enterpriseintegrationpatterns.com/patterns/messaging/Sequencer.html) * [Wolverine Cascading Messages](https://wolverine.netlify.app/guide/handlers/cascading.html) # Feature Flags > Runtime feature toggles with SaaS tiering, multi-level resolution, and hybrid cache ## Definition [Section titled “Definition”](#definition) The Feature Flags pattern enables activating or deactivating features at runtime without redeployment. Granit extends this pattern for SaaS tiering: feature resolution follows a multi-level cascade **Tenant > Plan > Default**, with a hybrid L1/L2 cache for performance. Three feature types are supported: * **Toggle**: enabled/disabled (boolean) * **Numeric**: numeric value with min/max constraints (SaaS quotas) * **Selection**: value from an allowed set of choices ## Diagram [Section titled “Diagram”](#diagram) ``` sequenceDiagram participant API as Endpoint / Handler participant FC as FeatureChecker participant HC as HybridCache (L1+L2) participant TVP as TenantValueProvider (20) participant PVP as PlanValueProvider (10) participant DVP as DefaultValueProvider (0) participant FS as IFeatureStore (DB) API->>FC: GetValueAsync("MaxUsers") FC->>HC: GetOrCreateAsync("t:{tid}:MaxUsers") alt Cache hit HC-->>FC: Cached value else Cache miss HC->>TVP: GetValueAsync(feature, tenantId) TVP->>FS: Read tenant override alt Override found FS-->>TVP: "500" TVP-->>HC: "500" else No override TVP-->>HC: null HC->>PVP: GetValueAsync(feature, planId) PVP-->>HC: "100" (plan value) end HC-->>HC: Store in L1 + L2 end FC-->>API: "500" or "100" ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) ### Feature definition (code-first) [Section titled “Feature definition (code-first)”](#feature-definition-code-first) | Component | File | Role | | --------------------------- | -------------------------------------------------------------- | --------------------------------------------------- | | `FeatureDefinition` | `src/Granit.Features/Definitions/FeatureDefinition.cs` | Name, default value, type, constraints | | `FeatureDefinitionProvider` | `src/Granit.Features/Definitions/FeatureDefinitionProvider.cs` | Abstract class to be implemented by the application | | `FeatureDefinitionStore` | `src/Granit.Features/Definitions/FeatureDefinitionStore.cs` | Singleton registry aggregating all providers | | `FeatureGroupDefinition` | `src/Granit.Features/Definitions/FeatureGroupDefinition.cs` | Logical grouping of features | ### Multi-level resolution [Section titled “Multi-level resolution”](#multi-level-resolution) | Provider | Order | File | Source | | ---------------------------------- | ----- | ------------------------------------------------------------------------ | --------------------------------------- | | `TenantFeatureValueProvider` | 20 | `src/Granit.Features/ValueProviders/TenantFeatureValueProvider.cs` | `IFeatureStore` (DB) | | `PlanFeatureValueProvider` | 10 | `src/Granit.Features/ValueProviders/PlanFeatureValueProvider.cs` | `IPlanFeatureStore` (application) | | `DefaultValueFeatureValueProvider` | 0 | `src/Granit.Features/ValueProviders/DefaultValueFeatureValueProvider.cs` | `FeatureDefinition.DefaultValue` (code) | ### Cache and invalidation [Section titled “Cache and invalidation”](#cache-and-invalidation) | Component | File | Role | | --------------------------------- | -------------------------------------------------------------- | --------------------------------------------------- | | `FeatureChecker` | `src/Granit.Features/Checker/FeatureChecker.cs` | Resolution orchestration + `HybridCache` | | `FeatureCacheKey` | `src/Granit.Features/Cache/FeatureCacheKey.cs` | Format: `t:{tenantId}:{featureName}` | | `FeatureCacheInvalidationHandler` | `src/Granit.Features/Cache/FeatureCacheInvalidationHandler.cs` | Listens to `FeatureValueChangedEvent`, purges cache | ### Numeric limit guard [Section titled “Numeric limit guard”](#numeric-limit-guard) | Component | File | Role | | -------------------- | -------------------------------------------------- | ---------------------------------------------------------------------------- | | `IFeatureLimitGuard` | `src/Granit.Features/Limits/IFeatureLimitGuard.cs` | `CheckAsync(feature, currentCount)` — throws `FeatureLimitExceededException` | | `FeatureLimitGuard` | `src/Granit.Features/Limits/FeatureLimitGuard.cs` | Implementation | ### ASP.NET Core + Wolverine integration [Section titled “ASP.NET Core + Wolverine integration”](#aspnet-core--wolverine-integration) | Component | File | Role | | ------------------------------- | ----------------------------------------------------------------- | ------------------------------ | | `[RequiresFeature]` | `src/Granit.Features/AspNetCore/RequiresFeatureAttribute.cs` | Attribute on actions/endpoints | | `RequiresFeatureFilter` | `src/Granit.Features/AspNetCore/RequiresFeatureFilter.cs` | `IAsyncActionFilter` for MVC | | `RequiresFeatureEndpointFilter` | `src/Granit.Features/AspNetCore/RequiresFeatureEndpointFilter.cs` | Minimal API filter | | `RequiresFeatureMiddleware` | `src/Granit.Features/Wolverine/RequiresFeatureMiddleware.cs` | Wolverine handler middleware | ## Rationale [Section titled “Rationale”](#rationale) | Problem | Solution | | ----------------------------------------------------------------------- | ------------------------------------------------------------------------ | | Different SaaS plans (Free/Pro/Enterprise) with different limits | `Numeric` features with `NumericConstraint` + `FeatureLimitGuard` | | Per-tenant override without redeployment | `TenantFeatureValueProvider` reads overrides from DB | | Performance: resolution must not query the DB on every request | `HybridCache` L1 (in-memory) + L2 (Redis) with event-driven invalidation | | Multi-instance consistency: a feature change must be visible everywhere | `FeatureValueChangedEvent` purges L1 and L2 cache via Wolverine | | API protection: block access if the feature is disabled | `[RequiresFeature]` on MVC, Minimal API, and Wolverine handlers | ## Usage example [Section titled “Usage example”](#usage-example) ```csharp // 1. Define features (code-first) public sealed class AcmeFeatureDefinitionProvider : FeatureDefinitionProvider { public override void Define(IFeatureDefinitionContext context) { FeatureGroupDefinition group = context.AddGroup("Acme"); group.AddFeature("Acme.MaxUsers", defaultValue: "50", valueType: FeatureValueType.Numeric, numericConstraint: new NumericConstraint(Min: 1, Max: 10_000)); group.AddFeature("Acme.Telehealth", defaultValue: "false", valueType: FeatureValueType.Toggle); } } // 2. Check in a handler public static class CreatePatientHandler { public static async Task Handle( CreatePatientCommand command, IFeatureLimitGuard limitGuard, IFeatureChecker features, PatientDbContext db, CancellationToken cancellationToken) { // Throws FeatureLimitExceededException if quota is reached long currentCount = await db.Patients.CountAsync(ct); await limitGuard.CheckAsync("Acme.MaxUsers", currentCount, ct); // Check that a feature toggle is enabled await features.RequireEnabledAsync("Acme.Telehealth", ct); // Business logic... } } // 3. Protect an endpoint app.MapPost("/api/patients", CreatePatientEndpoint.Handle) .RequiresFeature("Acme.MaxUsers"); ``` ## Further reading [Section titled “Further reading”](#further-reading) * [Feature Management — Microsoft Azure App Configuration](https://learn.microsoft.com/en-us/azure/azure-app-configuration/concept-feature-management) # Granit-Specific Variants > 10 hybrid patterns unique to Granit — adaptations of classic patterns for GDPR, multi-tenancy, and Wolverine Some patterns in Granit are variants or hybrids of classic patterns, adapted to the framework’s specific constraints (GDPR/ISO 27001, multi-tenancy, Wolverine). This page catalogues the 10 main “in-house” variants. ## 1. AsyncLocal Singleton (thread-safe context) [Section titled “1. AsyncLocal Singleton (thread-safe context)”](#1-asynclocal-singleton-thread-safe-context) **Classic pattern**: Singleton with a single global instance. **Granit variant**: `static readonly AsyncLocal` — a singleton state *per async flow*, thread-safe without locks. src/Granit.MultiTenancy/CurrentTenant.cs ```csharp private static readonly AsyncLocal _current = new(); ``` The state is inherited by child flows (`Task.Run`) but modifiable independently thanks to copy-on-write semantics. ## 2. ImmutableDictionary Copy-on-Write [Section titled “2. ImmutableDictionary Copy-on-Write”](#2-immutabledictionary-copy-on-write) **Classic pattern**: Copy-on-Write on data structures. **Granit variant**: `AsyncLocal>` prevents mutations in a child flow from propagating to the parent flow. src/Granit.Core/DataFiltering/DataFilter.cs ```csharp _state.Value = _state.Value!.SetItem(typeof(TFilter), false); // New dictionary created — the original remains intact ``` This specifically solves the “AsyncLocal trap” where a shared `Dictionary` would see child mutations affecting the parent. ## 3. Null Object as Soft Dependency [Section titled “3. Null Object as Soft Dependency”](#3-null-object-as-soft-dependency) **Classic pattern**: Null Object with a single interface. **Granit variant**: `NullTenantContext` is the **DI default**, replaced only when `Granit.MultiTenancy` is installed. All modules access `ICurrentTenant` via `Granit.Core.MultiTenancy` without a direct dependency on `Granit.MultiTenancy`. The Null Object is architecturally a **soft dependency** mechanism — an optional package does not break packages that implicitly depend on it. ## 4. Enum-based Strategy Selection [Section titled “4. Enum-based Strategy Selection”](#4-enum-based-strategy-selection) **Classic pattern**: Strategy with interface + factory. **Granit variant**: `TenantIsolationStrategy` is a simple enum. The factory uses a switch expression to select the implementation. ```csharp TenantIsolationStrategy.SharedDatabase => new SharedDatabaseDbContextFactory(...), TenantIsolationStrategy.SchemaPerTenant => new TenantPerSchemaDbContextFactory(...), TenantIsolationStrategy.DatabasePerTenant => new TenantPerDatabaseDbContextFactory(...), ``` Simpler than an abstract factory when the number of strategies is finite and known at compile time. ## 5. Property-based Proxy for EF Core [Section titled “5. Property-based Proxy for EF Core”](#5-property-based-proxy-for-ef-core) **Classic pattern**: Proxy with method delegation. **Granit variant**: `FilterProxy` exposes **properties** (not methods) because EF Core can only translate property accesses in query filters. src/Granit.Persistence/Extensions/ModelBuilderExtensions.cs ```csharp internal sealed class FilterProxy(IDataFilter? dataFilter, ICurrentTenant? tenant) { public bool SoftDeleteEnabled => dataFilter?.IsEnabled() ?? true; } ``` This works around an EF Core limitation, invisible to application developers. ## 6. Dual Sync/Async Template Method [Section titled “6. Dual Sync/Async Template Method”](#6-dual-syncasync-template-method) **Classic pattern**: Template Method with abstract hooks. **Granit variant**: `GranitModule` exposes sync/async pairs. The async version delegates to the sync version by default. ```csharp // A module can override one OR the other — not required to implement both public virtual Task ConfigureServicesAsync(ServiceConfigurationContext context) { ConfigureServices(context); return Task.CompletedTask; } ``` Avoids forcing async on simple modules while allowing it for modules that need it (e.g., Vault secret retrieval during startup). ## 7. Implicit Outbox via Handler Return Type [Section titled “7. Implicit Outbox via Handler Return Type”](#7-implicit-outbox-via-handler-return-type) **Classic pattern**: Explicit transactional outbox. **Granit variant** (native Wolverine): a handler returning `IEnumerable` automatically produces Outbox messages. ```csharp public static IEnumerable Handle(CreatePatientCommand cmd, AppDbContext db) { db.Patients.Add(new Patient { /* ... */ }); yield return new PatientCreatedOccurred { /* ... */ }; // local queue yield return new SendWelcomeEmailCommand { /* ... */ }; // Outbox } ``` The handler is pure and declarative. Infrastructure (Outbox, routing) is entirely managed by Wolverine. ## 8. Zero-allocation Streaming Hash [Section titled “8. Zero-allocation Streaming Hash”](#8-zero-allocation-streaming-hash) **Classic pattern**: SHA-256 on a complete buffer. **Granit variant**: `IncrementalHash.CreateHash(SHA256)` + `ArrayPool.Shared` in the idempotency middleware. ```text src/Granit.Idempotency/Internal/IdempotencyMiddleware.cs ``` The HTTP body is hashed in streaming mode without allocating a complete buffer, critical for large requests (uploads). ## 9. Multi-tenant Composite Key (idempotency) [Section titled “9. Multi-tenant Composite Key (idempotency)”](#9-multi-tenant-composite-key-idempotency) **Classic pattern**: Idempotency key = client header. **Granit variant**: The Redis key includes `{tenantId}:{userId}:{key}`, guaranteeing multi-tenant and multi-user isolation. Two different tenants can use the same idempotency key without collision. A single user with two clients cannot replay another client’s request. ## 10. Expression Tree Query Filters [Section titled “10. Expression Tree Query Filters”](#10-expression-tree-query-filters) **Classic pattern**: Static EF Core query filters. **Granit variant**: `ApplyGranitConventions()` dynamically builds a combined expression via `Expression.AndAlso()` for each entity. ```text src/Granit.Persistence/Extensions/ModelBuilderExtensions.cs ``` This solves the EF Core problem where multiple calls to `HasQueryFilter()` overwrite previous filters. The single combined expression handles `ISoftDeletable` + `IActive` + `IMultiTenant` + `IProcessingRestrictable` + `IPublishable` together. # Guard Clause (Fail-Fast) > How Granit uses semantic exceptions and guard clauses to produce RFC 7807 ProblemDetails responses ## Definition [Section titled “Definition”](#definition) The Guard Clause pattern validates preconditions at the start of a method and immediately throws a semantic exception if a condition is not met. This pattern prevents invalid states from propagating and produces clear, actionable error messages. ## Diagram [Section titled “Diagram”](#diagram) ``` flowchart TD E[Method entry] --> G1{blobId valid?} G1 -->|no| X1["throw BlobNotFoundException"] G1 -->|yes| G2{Status = Valid?} G2 -->|no| X2["throw BlobNotValidException"] G2 -->|yes| G3{tenant available?} G3 -->|no| X3["throw ForbiddenException"] G3 -->|yes| BL[Business logic] X1 --> PD["GranitExceptionHandler
ProblemDetails RFC 7807"] X2 --> PD X3 --> PD ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) ### Semantic exceptions [Section titled “Semantic exceptions”](#semantic-exceptions) | Exception | File | HTTP Code | ErrorCode | | ------------------------------- | ----------------------------------------------------------------- | --------- | ------------------------ | | `BusinessException` | `src/Granit.Core/Exceptions/BusinessException.cs` | 400 | Configurable | | `NotFoundException` | `src/Granit.Core/Exceptions/NotFoundException.cs` | 404 | — | | `EntityNotFoundException` | `src/Granit.Core/Exceptions/EntityNotFoundException.cs` | 404 | — | | `ConflictException` | `src/Granit.Core/Exceptions/ConflictException.cs` | 409 | Configurable | | `ForbiddenException` | `src/Granit.Core/Exceptions/ForbiddenException.cs` | 403 | — | | `ValidationException` | `src/Granit.Core/Exceptions/ValidationException.cs` | 422 | Field errors | | `BlobNotFoundException` | `src/Granit.BlobStorage/Exceptions/BlobNotFoundException.cs` | 404 | `BlobStorage:NotFound` | | `FeatureLimitExceededException` | `src/Granit.Features/Exceptions/FeatureLimitExceededException.cs` | 429 | `Features:LimitExceeded` | | `FeatureNotEnabledException` | `src/Granit.Features/Exceptions/FeatureNotEnabledException.cs` | 403 | `Features:NotEnabled` | All exceptions are intercepted by `GranitExceptionHandler` (`src/Granit.ExceptionHandling/GranitExceptionHandler.cs`) and converted to RFC 7807 `ProblemDetails`. ### ISO 27001 rule [Section titled “ISO 27001 rule”](#iso-27001-rule) Exceptions that do not implement `IUserFriendlyException` have their message masked in production (“An unexpected error occurred”) to prevent leaking internal schema details. ## Rationale [Section titled “Rationale”](#rationale) Guard clauses make preconditions explicit and document each method’s contract. Semantic exceptions allow the global middleware to produce appropriate HTTP responses without each endpoint managing its own errors. ## Usage example [Section titled “Usage example”](#usage-example) ```csharp public static class DownloadDocumentHandler { public static async Task Handle( DownloadDocumentQuery query, IBlobStorage blobStorage, CancellationToken cancellationToken) { // Guard clause -- throws BlobNotFoundException (404) BlobDescriptor? descriptor = await blobStorage.GetDescriptorAsync( "medical-documents", query.BlobId, ct) ?? throw new BlobNotFoundException(query.BlobId); // Guard clause -- throws BlobNotValidException (409) if (descriptor.Status != BlobStatus.Valid) throw new BlobNotValidException(query.BlobId, descriptor.Status); // Business logic -- preconditions are guaranteed return await blobStorage.CreateDownloadUrlAsync( "medical-documents", query.BlobId, cancellationToken: ct); } } ``` ## Further reading [Section titled “Further reading”](#further-reading) * [Guard Clause — deviq.com](https://deviq.com/design-patterns/guard-clause) # Hexagonal Architecture > Ports and adapters pattern isolating business logic from infrastructure concerns ## Definition [Section titled “Definition”](#definition) Hexagonal architecture separates business logic (the “core”) from infrastructure details (databases, cloud services, frameworks) via **ports** (interfaces) and **adapters** (interchangeable implementations). The core only knows about ports; adapters are wired at composition time (DI). In Granit, each functional module (BlobStorage, Features, BackgroundJobs, Webhooks, Settings) follows this pattern: a “core” package defines the ports, and separate packages (`*.EntityFrameworkCore`, `*.S3`) provide the adapters. ## Diagram [Section titled “Diagram”](#diagram) ``` classDiagram direction LR class IBlobStorage { +InitiateUploadAsync() +CreateDownloadUrlAsync() +DeleteAsync() } class IBlobDescriptorStoreReader { +FindAsync() } class IBlobDescriptorStoreWriter { +SaveAsync() +UpdateAsync() } class IBlobStorageClient { +DeleteObjectAsync() +HeadObjectAsync() } class IBlobKeyStrategy { +BuildObjectKey() +ResolveBucketName() } class IBlobValidator { +ValidateAsync() } class DefaultBlobStorage { core adapter } class EfBlobDescriptorStore { EF Core adapter } class S3BlobClient { S3 adapter } class PrefixBlobKeyStrategy { S3 adapter } class MagicBytesValidator { built-in adapter } IBlobStorage <|.. DefaultBlobStorage DefaultBlobStorage --> IBlobDescriptorStoreReader DefaultBlobStorage --> IBlobDescriptorStoreWriter DefaultBlobStorage --> IBlobStorageClient DefaultBlobStorage --> IBlobKeyStrategy DefaultBlobStorage --> IBlobValidator IBlobDescriptorStoreReader <|.. EfBlobDescriptorStore IBlobDescriptorStoreWriter <|.. EfBlobDescriptorStore IBlobStorageClient <|.. S3BlobClient IBlobKeyStrategy <|.. PrefixBlobKeyStrategy IBlobValidator <|.. MagicBytesValidator ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) ### BlobStorage (primary example) [Section titled “BlobStorage (primary example)”](#blobstorage-primary-example) | Port (interface) | File | Adapter(s) | | ----------------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------- | | `IBlobStorage` | `src/Granit.BlobStorage/IBlobStorage.cs` | `DefaultBlobStorage` (orchestrator) | | `IBlobDescriptorStoreReader` / `IBlobDescriptorStoreWriter` | `src/Granit.BlobStorage/` | `EfBlobDescriptorStore` in `Granit.BlobStorage.EntityFrameworkCore` | | `IBlobStorageClient` | `src/Granit.BlobStorage/Internal/IBlobStorageClient.cs` | `S3BlobClient` in `Granit.BlobStorage.S3` | | `IBlobKeyStrategy` | `src/Granit.BlobStorage/IBlobKeyStrategy.cs` | `PrefixBlobKeyStrategy` in `Granit.BlobStorage.S3` | | `IBlobValidator` | `src/Granit.BlobStorage/IBlobValidator.cs` | `MagicBytesValidator`, `MaxSizeValidator` (built-in) + custom | ### Same pattern across other modules [Section titled “Same pattern across other modules”](#same-pattern-across-other-modules) | Module | Port | Adapters | | -------------- | --------------------------------------------------------------------- | ---------------------------------------------------- | | Features | `IFeatureStoreReader` / `IFeatureStoreWriter` | `InMemoryFeatureStore`, `EfCoreFeatureStore` | | BackgroundJobs | `IBackgroundJobStoreReader` / `IBackgroundJobStoreWriter` | `InMemoryBackgroundJobStore`, `EfBackgroundJobStore` | | Webhooks | `IWebhookSubscriptionStoreReader` / `IWebhookSubscriptionStoreWriter` | `EfWebhookSubscriptionStore` | | Settings | `ISettingStoreReader` / `ISettingStoreWriter` | `EfCoreSettingStore` | | Caching | `ICacheService(T)` | `DistributedCacheService`, `HybridCacheService` | | Encryption | `IStringEncryptionProvider` | `AesStringEncryptionProvider` | ## Rationale [Section titled “Rationale”](#rationale) | Problem | Solution | | -------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | Coupling to a cloud provider (S3, Azure Blob) | Ports allow swapping adapters without touching the core | | Unit tests requiring a database | `InMemoryFeatureStore` and `InMemoryBackgroundJobStore` implement Reader/Writer interfaces, replacing EF Core in tests | | ISO 27001 compliance — ability to migrate from S3-compatible storage to a sovereign provider | Implementing `IBlobStorageClient` for the new provider is sufficient | | Independent NuGet packages | The core (`Granit.BlobStorage`) has no dependency on EF Core or the AWS SDK | ## Usage example [Section titled “Usage example”](#usage-example) ```csharp // Replacing S3 with MinIO -- only the adapter changes services.AddSingleton(); services.AddSingleton(); // The rest of the application code remains unchanged IBlobStorage blobStorage = serviceProvider.GetRequiredService(); PresignedUploadTicket ticket = await blobStorage.InitiateUploadAsync( "medical-documents", new BlobUploadRequest("mri-report.pdf", "application/pdf", MaxAllowedBytes: 50_000_000), cancellationToken); ``` ## Further reading [Section titled “Further reading”](#further-reading) * [Hexagonal Architecture — Alistair Cockburn (original article, 2005)](https://alistair.cockburn.us/hexagonal-architecture/) # Idempotency > Stripe-style HTTP idempotency with state machine, SHA-256 payload hashing, and Redis store ## Definition [Section titled “Definition”](#definition) Idempotency guarantees that the same HTTP request, replayed multiple times, always produces the same result without additional side effects. The client sends an `Idempotency-Key` header; the server associates this key with the response and replays it on retry. Granit implements a Stripe-inspired variant with a **state machine** (Absent > InProgress > Completed), a SHA-256 payload hash to detect mutations, and a multi-tenant Redis store. ## Diagram [Section titled “Diagram”](#diagram) ``` flowchart TD REQ([New request with
Idempotency-Key]) --> LOOKUP{Key exists
in Redis?} LOOKUP -->|No| LOCK["Acquire lock
(SET NX PX)"] LOCK --> EXEC[Execute handler] EXEC -->|Success| CACHE["Cache response
+ SHA-256 hash"] CACHE --> R200([200 — Original response]) EXEC -->|Failure 5xx| REL["Release lock
(DEL key)"] REL --> R5XX([5xx — Error propagated]) LOOKUP -->|Yes, InProgress| R409([409 + Retry-After]) LOOKUP -->|Yes, Completed| HASH{Payload hash
matches?} HASH -->|Same| REPLAY([200 — Cached response]) HASH -->|Different| REJECT([422 — Payload mismatch]) ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) | Component | File | Role | | ----------------------- | ------------------------------------------------------------- | -------------------------------------------- | | `IdempotencyMiddleware` | `src/Granit.Idempotency/Internal/IdempotencyMiddleware.cs` | ASP.NET Core middleware — full state machine | | `IdempotencyState` | `src/Granit.Idempotency/Models/IdempotencyState.cs` | Enum: `Absent`, `InProgress`, `Completed` | | `[Idempotent]` | `src/Granit.Idempotency/Attributes/IdempotentAttribute.cs` | Marker attribute on endpoints | | `RedisIdempotencyStore` | `src/Granit.Idempotency/Redis/RedisIdempotencyStore.cs` | Redis store with configurable TTL | | `IIdempotencyMetadata` | `src/Granit.Idempotency/Abstractions/IIdempotencyMetadata.cs` | Marker interface for endpoints | ### Detailed algorithm [Section titled “Detailed algorithm”](#detailed-algorithm) 1. Extract the `Idempotency-Key` header 2. Compute SHA-256 hash: `METHOD + route + key + body` (zero-allocation via `IncrementalHash` + `ArrayPool`) 3. Composite Redis key: `idempotency:{tenantId}:{userId}:{key}` 4. Check state: * **Absent** — attempts `SET NX PX` (lock with TTL) * **InProgress** — returns `409 Conflict` + `Retry-After` header * **Completed** + same hash — replays the response (status + headers + body) * **Completed** + different hash — returns `422 Unprocessable Entity` 5. After successful execution: stores the full response 6. After failure (5xx): releases the lock (allows retry) ### In-house variants [Section titled “In-house variants”](#in-house-variants) * **Multi-tenant composite key**: includes `tenantId` + `userId` for isolation * **Zero-allocation hash**: `IncrementalHash.CreateHash(HashAlgorithmName.SHA256)` + `ArrayPool.Shared` for body streaming * **Double-check locking**: checks the cache before AND after lock acquisition ## Rationale [Section titled “Rationale”](#rationale) | Problem | Solution | | ------------------------------------------------------------------ | ------------------------------------------------------------------ | | Network retry creates duplicates (double payment, double creation) | The idempotency key detects and replays already-processed requests | | Concurrent requests with the same key | `InProgress` state returns 409 — the client waits and retries | | Payload mutation between two sends | The SHA-256 hash detects the difference and returns 422 | | Performance: no lock on normal requests (without key) | The middleware short-circuits immediately if no header present | | Multi-tenant: one tenant’s key must not impact another | Redis key prefixed with `{tenantId}:{userId}` | ## Usage example [Section titled “Usage example”](#usage-example) ```csharp // Mark an endpoint as idempotent app.MapPost("/api/invoices", async ( CreateInvoiceRequest request, InvoiceService service, CancellationToken cancellationToken) => { InvoiceDto invoice = await service.CreateAsync(request, ct); return Results.Created($"/api/invoices/{invoice.Id}", invoice); }) .WithMetadata(new IdempotentAttribute()); // The client sends: // POST /api/invoices // Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000 // Content-Type: application/json // { "patientId": "...", "amount": 150.00 } // First call -> 201 Created (result stored) // Second call (same key, same body) -> 201 Created (replay) // Second call (same key, modified body) -> 422 Unprocessable Entity ``` ## Further reading [Section titled “Further reading”](#further-reading) * [Idempotent Requests — Stripe API Documentation](https://docs.stripe.com/api/idempotent_requests) # Layered Architecture > Three-layer structure with strict dependency direction from infrastructure to domain ## Definition [Section titled “Definition”](#definition) Layered architecture organizes code into strictly hierarchical levels of responsibility. Each layer depends only on the layer below it, never on the layer above. In Granit, three main layers structure each module: * **Domain**: entities, marker interfaces, events, business exceptions * **Application**: orchestration services, checkers, managers * **Infrastructure**: EF Core persistence, S3 clients, Vault, Redis cache ## Diagram [Section titled “Diagram”](#diagram) ``` flowchart BT subgraph Infrastructure EF["*.EntityFrameworkCore
(DbContext, EF stores)"] S3["*.S3
(S3BlobClient)"] VAULT["Granit.Vault
(VaultClientFactory)"] CACHE["Granit.Caching
(DistributedCacheService)"] end subgraph Application BM["BackgroundJobManager"] FC["FeatureChecker"] BS["DefaultBlobStorage"] EH["GranitExceptionHandler"] end subgraph Domain ENT["Entity, AuditedEntity
FullAuditedEntity"] INT["IBlobStorage, IFeatureStore
IBackgroundJobStore"] EVT["IDomainEvent
IIntegrationEvent"] EXC["BusinessException
NotFoundException"] MRK["ISoftDeletable, IMultiTenant
IActive"] end EF -->|implements| INT S3 -->|implements| INT VAULT -->|used by| Application CACHE -->|used by| Application BM -->|uses| INT FC -->|uses| INT BS -->|uses| INT Application -->|manipulates| ENT Application -->|publishes| EVT Application -->|throws| EXC style Domain fill:#2d5a27,color:#fff style Application fill:#4a9eff,color:#fff style Infrastructure fill:#ff6b6b,color:#fff ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) ### Domain layer (`Granit.Core`) [Section titled “Domain layer (Granit.Core)”](#domain-layer-granitcore) | Component | File | | -------------------------------------------------------------------------- | ------------------------------- | | `Entity` > `CreationAuditedEntity` > `AuditedEntity` > `FullAuditedEntity` | `src/Granit.Core/Domain/` | | `ISoftDeletable`, `IMultiTenant`, `IActive` | `src/Granit.Core/Domain/` | | `IDomainEvent`, `IIntegrationEvent` | `src/Granit.Core/Events/` | | `BusinessException`, `NotFoundException`, `ConflictException` | `src/Granit.Core/Exceptions/` | | `ICurrentTenant`, `NullTenantContext` | `src/Granit.Core/MultiTenancy/` | ### Application layer (functional packages) [Section titled “Application layer (functional packages)”](#application-layer-functional-packages) | Service | File | | ------------------------ | ------------------------------------------------------------ | | `FeatureChecker` | `src/Granit.Features/Checker/FeatureChecker.cs` | | `BackgroundJobManager` | `src/Granit.BackgroundJobs/Internal/BackgroundJobManager.cs` | | `DefaultBlobStorage` | `src/Granit.BlobStorage/Internal/DefaultBlobStorage.cs` | | `PermissionChecker` | `src/Granit.Authorization/Services/PermissionChecker.cs` | | `GranitExceptionHandler` | `src/Granit.ExceptionHandling/GranitExceptionHandler.cs` | ### Infrastructure layer (`*.EntityFrameworkCore`, `*.S3`, etc.) [Section titled “Infrastructure layer (\*.EntityFrameworkCore, \*.S3, etc.)”](#infrastructure-layer-entityframeworkcore-s3-etc) | Component | File | | ----------------------- | -------------------------------------------------------------------------------- | | `EfBlobDescriptorStore` | `src/Granit.BlobStorage.EntityFrameworkCore/Internal/EfBlobDescriptorStore.cs` | | `EfCoreFeatureStore` | `src/Granit.Features.EntityFrameworkCore/Internal/EfCoreFeatureStore.cs` | | `EfBackgroundJobStore` | `src/Granit.BackgroundJobs.EntityFrameworkCore/Internal/EfBackgroundJobStore.cs` | | `S3BlobClient` | `src/Granit.BlobStorage.S3/Internal/S3BlobClient.cs` | | `VaultClientFactory` | `src/Granit.Vault/Services/VaultClientFactory.cs` | ### Dependency rule [Section titled “Dependency rule”](#dependency-rule) ```text Infrastructure --> Application --> Domain Never in the reverse direction ``` `*.EntityFrameworkCore` packages reference the core package (e.g., `Granit.BlobStorage`) but never the other way around. The core package contains no dependency on EF Core or the AWS SDK. ## Rationale [Section titled “Rationale”](#rationale) | Problem | Solution | | ------------------------------------------------------------- | ---------------------------------------------- | | Coupling between business logic and database | The domain only knows about interfaces (ports) | | Difficulty testing business logic in isolation | Application services are testable with mocks | | Changing provider (S3 to another) impacts the entire codebase | Only the infrastructure layer changes | | EF Core entities leaking into API DTOs | Strict separation prevents shortcuts | ## Usage example [Section titled “Usage example”](#usage-example) ```csharp // Entity hierarchy -- Domain layer public sealed class Patient : FullAuditedEntity, IMultiTenant { public Guid? TenantId { get; set; } public string FirstName { get; set; } = string.Empty; public string LastName { get; set; } = string.Empty; public DateOnly BirthDate { get; set; } } // FullAuditedEntity automatically provides: // - Id (Guid, sequential via IGuidGenerator) // - CreatedAt, CreatedBy (ISO 27001 audit -- creation) // - ModifiedAt, ModifiedBy (ISO 27001 audit -- modification) // - IsDeleted, DeletedAt, DeletedBy (GDPR soft delete) // - TenantId (multi-tenant isolation via IMultiTenant) ``` ## Further reading [Section titled “Further reading”](#further-reading) * [Presentation Domain Data Layering — Martin Fowler](https://martinfowler.com/bliki/PresentationDomainDataLayering.html) # Marker Interface > How Granit uses marker interfaces to apply cross-cutting behaviors declaratively ## Definition [Section titled “Definition”](#definition) A Marker Interface is an interface with no methods (or minimal properties) that signals that a type possesses a specific behavior or characteristic. Framework components detect these interfaces via reflection and apply the associated behavior. ## Diagram [Section titled “Diagram”](#diagram) ``` classDiagram class ISoftDeletable { <> +IsDeleted : bool +DeletedAt : DateTimeOffset? +DeletedBy : string? } class IMultiTenant { <> +TenantId : Guid? } class IActive { <> +IsActive : bool } class IDomainEvent { <> } class IIntegrationEvent { <> } class IUserFriendlyException { <> } class IHasErrorCode { <> +ErrorCode : string } note for ISoftDeletable "SoftDeleteInterceptor
Query filter" note for IMultiTenant "AuditedEntityInterceptor
Query filter" note for IDomainEvent "Local queue Wolverine" note for IIntegrationEvent "Outbox Wolverine" note for IUserFriendlyException "Message exposed to client" ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) ### Entity markers [Section titled “Entity markers”](#entity-markers) | Interface | File | Detected by | | ---------------- | ------------------------------------------ | ------------------------------------------------------ | | `ISoftDeletable` | `src/Granit.Core/Domain/ISoftDeletable.cs` | `SoftDeleteInterceptor`, `ApplyGranitConventions()` | | `IMultiTenant` | `src/Granit.Core/Domain/IMultiTenant.cs` | `AuditedEntityInterceptor`, `ApplyGranitConventions()` | | `IActive` | `src/Granit.Core/Domain/IActive.cs` | `ApplyGranitConventions()` | ### Event markers [Section titled “Event markers”](#event-markers) | Interface | File | Detected by | | ------------------- | --------------------------------------------- | ------------------------------------ | | `IDomainEvent` | `src/Granit.Core/Events/IDomainEvent.cs` | Wolverine routing — local queue | | `IIntegrationEvent` | `src/Granit.Core/Events/IIntegrationEvent.cs` | Wolverine routing — transport/Outbox | ### Exception markers [Section titled “Exception markers”](#exception-markers) | Interface | File | Detected by | | ------------------------ | ------------------------------------------------------ | ------------------------------------------------------- | | `IUserFriendlyException` | `src/Granit.Core/Exceptions/IUserFriendlyException.cs` | `GranitExceptionHandler` — message exposed to client | | `IHasErrorCode` | `src/Granit.Core/Exceptions/IHasErrorCode.cs` | `GranitExceptionHandler` — error code in ProblemDetails | | `IHasValidationErrors` | `src/Granit.Core/Exceptions/IHasValidationErrors.cs` | `GranitExceptionHandler` — field errors in extensions | ### Idempotency markers [Section titled “Idempotency markers”](#idempotency-markers) | Interface | File | Detected by | | ---------------------- | ------------------------------------------------------------- | ------------------------------------------------------------- | | `IIdempotencyMetadata` | `src/Granit.Idempotency/Abstractions/IIdempotencyMetadata.cs` | `IdempotencyMiddleware` — enables idempotency on the endpoint | ## Rationale [Section titled “Rationale”](#rationale) Markers allow applying cross-cutting behaviors (audit, filtering, routing) declaratively, without coupling entities to infrastructure frameworks. An entity implementing `ISoftDeletable` automatically gets soft delete and the query filter — no additional code required. ## Usage example [Section titled “Usage example”](#usage-example) ```csharp // The entity declares its characteristics via markers public sealed class MedicalRecord : FullAuditedEntity, IMultiTenant, IActive { public Guid? TenantId { get; set; } // <- IMultiTenant public bool IsActive { get; set; } = true; // <- IActive // ISoftDeletable is inherited from FullAuditedEntity public string Diagnosis { get; set; } = string.Empty; } // The framework detects markers and automatically applies: // - Query filter: WHERE IsDeleted=false AND IsActive=true AND TenantId=@tid // - Audit interceptor: CreatedAt/By, ModifiedAt/By, TenantId // - Soft delete interceptor: DELETE -> UPDATE IsDeleted=true ``` # Mediator > Wolverine as the central mediator for commands, events, and queries in Granit ## Definition [Section titled “Definition”](#definition) The Mediator pattern centralizes interactions between components via an intermediary object. Components do not communicate directly; they send messages to the mediator, which routes them to the appropriate recipients. In Granit, **Wolverine** is the central mediator: it routes commands, events, and queries to handlers, manages transactions, retries, and the Outbox. ## Diagram [Section titled “Diagram”](#diagram) ``` flowchart TD H1[HTTP Handler] -->|command| W[Wolverine Bus] H2[Background Job] -->|command| W H3[Event Publisher] -->|event| W W -->|route| C1[CommandHandler A] W -->|route| C2[CommandHandler B] W -->|route| E1[EventHandler X] W -->|route| E2[EventHandler Y] W -->|manages| TX[Transactions] W -->|manages| RT[Retries] W -->|manages| OB[Outbox] W -->|manages| DLQ[Dead Letter Queue] style W fill:#4a9eff,color:#fff ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) | Component | File | Role | | --------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------- | | `GranitWolverineModule` | `src/Granit.Wolverine/GranitWolverineModule.cs` | Mediator configuration | | `AddGranitWolverine()` | `src/Granit.Wolverine/Extensions/WolverineHostApplicationBuilderExtensions.cs` | Registers policies (retry, validation, DLQ) | | `WolverineMessagingOptions` | `src/Granit.Wolverine/WolverineMessagingOptions.cs` | Retry delays: 5s, 30s, 5min | ### Policies managed by the mediator [Section titled “Policies managed by the mediator”](#policies-managed-by-the-mediator) * **FluentValidation**: invalid messages go to DLQ immediately * **Exponential retry**: `ValidationException` goes to DLQ, others retry at 5s/30s/5min * **Outbox**: `IIntegrationEvent` messages persisted atomically * **Local queue**: `IDomainEvent` messages processed in-process ## Rationale [Section titled “Rationale”](#rationale) Without a mediator, each handler would need to know which other handlers to call and manage its own transactions and retries. Wolverine centralizes this logic and keeps handlers pure and testable. ## Usage example [Section titled “Usage example”](#usage-example) ```csharp // Handlers do not know each other -- Wolverine routes messages public static class CreatePatientHandler { public static IEnumerable Handle( CreatePatientCommand cmd, AppDbContext db) { Patient patient = new() { /* ... */ }; db.Patients.Add(patient); // Wolverine routes to the correct handler automatically yield return new PatientCreatedOccurred { PatientId = patient.Id }; yield return new SendWelcomeEmailCommand { Email = cmd.Email }; } } // PatientCreatedOccurred -> local queue -> domain handler (same tx) // SendWelcomeEmailCommand -> Outbox -> background handler (after commit) ``` ## Further reading [Section titled “Further reading”](#further-reading) * [Mediator — refactoring.guru](https://refactoring.guru/design-patterns/mediator) # Middleware Pipeline > Dual ASP.NET Core and Wolverine middleware pipeline for cross-cutting context propagation ## Definition [Section titled “Definition”](#definition) The Middleware Pipeline pattern chains interceptor components around the main processing of a request or message. Each middleware can execute logic before and after the handler, short-circuit the chain, or enrich the context. Granit implements a **dual pipeline**: 1. **ASP.NET Core Middleware**: for HTTP requests (tenant resolution, idempotency) 2. **Wolverine Behaviors/Middleware**: for asynchronous messages (tenant, user, and trace context propagation) Both pipelines converge through `OutgoingContextMiddleware`, which injects context headers into outgoing Wolverine envelopes. ## Diagram [Section titled “Diagram”](#diagram) ``` sequenceDiagram participant C as Client HTTP participant TRM as TenantResolutionMiddleware participant IDM as IdempotencyMiddleware participant H as HTTP Handler participant OCM as OutgoingContextMiddleware participant OB as Wolverine Outbox participant TCB as TenantContextBehavior participant UCB as UserContextBehavior participant TrCB as TraceContextBehavior participant BH as Background Handler C->>TRM: HTTP Request TRM->>TRM: Resolves X-Tenant-Id or JWT claim TRM->>IDM: next() IDM->>IDM: Checks Idempotency-Key IDM->>H: next() H->>OCM: Publishes message OCM->>OCM: Injects X-Tenant-Id, X-User-Id, traceparent OCM->>OB: Envelope + headers H-->>C: HTTP Response Note over OB,BH: Asynchronous processing OB->>TCB: Dispatch message TCB->>TCB: Restores ICurrentTenant from header TCB->>UCB: Before() UCB->>UCB: Restores ICurrentUserService via AsyncLocal UCB->>TrCB: Before() TrCB->>TrCB: Links Activity to traceparent TrCB->>BH: Handler.HandleAsync() BH-->>TrCB: Result TrCB-->>UCB: After() UCB-->>TCB: After() ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) ### ASP.NET Core pipeline [Section titled “ASP.NET Core pipeline”](#aspnet-core-pipeline) | Middleware | File | Role | | ---------------------------- | ------------------------------------------------------------------ | --------------------------------------------------------------- | | `TenantResolutionMiddleware` | `src/Granit.MultiTenancy/Middleware/TenantResolutionMiddleware.cs` | Resolves the tenant via `TenantResolverPipeline` (Header > JWT) | | `IdempotencyMiddleware` | `src/Granit.Idempotency/Internal/IdempotencyMiddleware.cs` | Stripe-style HTTP idempotency with state machine | ### Wolverine pipeline — incoming behaviors [Section titled “Wolverine pipeline — incoming behaviors”](#wolverine-pipeline--incoming-behaviors) | Behavior | File | Role | | ----------------------- | --------------------------------------------------------- | ---------------------------------------------------------------- | | `TenantContextBehavior` | `src/Granit.Wolverine/Behaviors/TenantContextBehavior.cs` | Restores `ICurrentTenant` from `X-Tenant-Id` header | | `UserContextBehavior` | `src/Granit.Wolverine/Behaviors/UserContextBehavior.cs` | Restores `ICurrentUserService` via `IWolverineUserContextSetter` | | `TraceContextBehavior` | `src/Granit.Wolverine/Behaviors/TraceContextBehavior.cs` | Links the W3C `traceparent` to the handler’s Activity | ### Wolverine pipeline — outgoing middleware [Section titled “Wolverine pipeline — outgoing middleware”](#wolverine-pipeline--outgoing-middleware) | Middleware | File | Role | | --------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------- | | `OutgoingContextMiddleware` | `src/Granit.Wolverine/Middleware/OutgoingContextMiddleware.cs` | Injects `X-Tenant-Id`, `X-User-Id`, `traceparent` into outgoing envelopes | ### Global registration [Section titled “Global registration”](#global-registration) All behaviors and middlewares are registered in `src/Granit.Wolverine/Extensions/WolverineHostApplicationBuilderExtensions.cs` via `opts.Policies.AddMiddleware()`. ## Rationale [Section titled “Rationale”](#rationale) | Problem | Solution | | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | | The HTTP handler knows the tenant, but the background handler does not | `OutgoingContextMiddleware` > headers > `TenantContextBehavior` restores the context | | The EF Core audit interceptor needs `ModifiedBy` in background | `UserContextBehavior` restores `ICurrentUserService` via `AsyncLocal` | | OpenTelemetry traces are disjointed between HTTP and async | `TraceContextBehavior` links spans via `traceparent` (W3C Trace Context) | | Cross-cutting concerns pollute handlers | Logic extracted into reusable, composable middlewares | ## Usage example [Section titled “Usage example”](#usage-example) ```csharp // The handler has no awareness of middlewares -- // tenant/user/trace context is already restored when it executes public static class SendInvoiceHandler { // Wolverine discovers this handler automatically public static async Task Handle( SendInvoiceCommand command, ICurrentTenant currentTenant, // restored by TenantContextBehavior ICurrentUserService currentUser, // restored by UserContextBehavior InvoiceDbContext db, CancellationToken cancellationToken) { // currentTenant.Id is correct even in background // currentUser.UserId is correct for the audit trail Invoice invoice = await db.Invoices.FindAsync([command.InvoiceId], ct) ?? throw new EntityNotFoundException(typeof(Invoice), command.InvoiceId); invoice.MarkAsSent(); await db.SaveChangesAsync(ct); // AuditedEntityInterceptor records ModifiedBy = currentUser.UserId } } ``` ## Further reading [Section titled “Further reading”](#further-reading) * [Sidecar pattern — Microsoft Cloud Design Patterns](https://learn.microsoft.com/en-us/azure/architecture/patterns/sidecar) # Module System > ABP-inspired module system with topological sorting for deterministic startup ordering ## Definition [Section titled “Definition”](#definition) The Module System pattern organizes an application into self-contained units (modules), each owning its own service registration and initialization lifecycle. A central loader resolves startup order through topological sorting of declared dependencies, guaranteeing that a module never starts before its prerequisites. Granit implements a variant inspired by the ABP framework (ASP.NET Boilerplate), adapted for an ecosystem of independent NuGet packages. ## Diagram [Section titled “Diagram”](#diagram) ``` flowchart TD A[Application Host] -->|"AddGranit(TRootModule)"| B[ModuleLoader] B -->|1. Discovery| C["Recursive traversal of
[DependsOn] attributes"] C -->|2. Graph| D["Build dependency
DAG"] D -->|3. Topological sort| E["Kahn's algorithm
(cycle detection)"] E -->|4. ConfigureServices| F["Module A, Module B, ..., Root
(topological order)"] F -->|5. OnApplicationInitialization| G["Module A, Module B, ..., Root
(same order)"] style B fill:#4a9eff,color:#fff style E fill:#ff6b6b,color:#fff ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) | Component | File | Role | | ---------------------- | ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | `GranitModule` | `src/Granit.Core/Modularity/GranitModule.cs` | Abstract base class: `ConfigureServices()`, `ConfigureServicesAsync()`, `OnApplicationInitialization()`, `OnApplicationInitializationAsync()` | | `DependsOnAttribute` | `src/Granit.Core/Modularity/DependsOnAttribute.cs` | Declares module dependencies via `[DependsOn(typeof(...))]` | | `ModuleLoader` | `src/Granit.Core/Modularity/ModuleLoader.cs` | Topological sort (Kahn’s algorithm) with circular dependency detection | | `ModuleDescriptor` | `src/Granit.Core/Modularity/ModuleDescriptor.cs` | Module metadata (type, instance, dependencies) | | `GranitApplication` | `src/Granit.Core/Modularity/GranitApplication.cs` | Full lifecycle coordinator | | `AddGranit()` | `src/Granit.Core/Extensions/GranitHostBuilderExtensions.cs` | Entry point for the host application | **In-house variant — Dual Sync/Async**: the async hooks (`ConfigureServicesAsync`, `OnApplicationInitializationAsync`) delegate to their sync counterpart by default. A module can override either one without being required to implement both. ## Rationale [Section titled “Rationale”](#rationale) | Problem | Solution | | ------------------------------------------------------ | -------------------------------------------------------------------------- | | Independent NuGet packages that need to self-configure | Each package exposes a `GranitModule` with its own `ConfigureServices()` | | Unpredictable initialization order with native DI | Topological sort guarantees dependencies are registered first | | Silent circular dependencies | Kahn’s algorithm throws an explicit exception listing the involved modules | | Code duplication in application `Program.cs` files | A single `builder.AddGranit()` call replaces dozens of lines | ## Usage example [Section titled “Usage example”](#usage-example) ```csharp // Declaring an application module [DependsOn(typeof(GranitPersistenceModule))] [DependsOn(typeof(GranitWolverineModule))] [DependsOn(typeof(GranitFeaturesModule))] public sealed class MyAppHostModule : GranitModule { public override void ConfigureServices(ServiceConfigurationContext context) { ServiceCollection services = context.Services; services.AddScoped(); } public override void OnApplicationInitialization(ApplicationInitializationContext context) { WebApplication app = context.GetApplicationBuilder(); app.MapHealthChecks("/healthz"); } } // Entry point -- a single line WebApplicationBuilder builder = WebApplication.CreateBuilder(args); builder.AddGranit(); WebApplication app = builder.Build(); await app.UseGranitAsync(); app.Run(); ``` # Multi-Tenancy > Tenant isolation with three strategies, soft dependency, and async context propagation ## Definition [Section titled “Definition”](#definition) Multi-tenancy allows a single application instance to serve multiple organizations (tenants) with strict data isolation. Each request is associated with a tenant through a resolution pipeline, and this information flows across all layers — including asynchronous Wolverine processing. Granit implements three isolation strategies and a **soft dependency** mechanism: `ICurrentTenant` is available in all modules without a direct dependency on `Granit.MultiTenancy`. ## Diagram [Section titled “Diagram”](#diagram) ``` flowchart TD REQ[HTTP Request] --> PIPE[TenantResolverPipeline] PIPE --> HR["HeaderTenantResolver
(Order = 100)"] HR -->|found| CTX[CurrentTenant via AsyncLocal] HR -->|not found| JR["JwtClaimTenantResolver
(Order = 200)"] JR -->|found| CTX JR -->|not found| NULL["NullTenantContext
IsAvailable = false"] CTX --> STRAT{Isolation strategy} STRAT -->|SharedDatabase| QF["EF Core Query Filter
WHERE TenantId = @tid"] STRAT -->|SchemaPerTenant| SP["SET search_path TO
tenant_{tid}"] STRAT -->|DatabasePerTenant| DB["Dedicated connection
string per tenant"] CTX --> OCM["OutgoingContextMiddleware
injects X-Tenant-Id"] OCM --> WOL["Wolverine Outbox"] WOL --> TCB["TenantContextBehavior
restores ICurrentTenant"] TCB --> BH[Background Handler] ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) ### Soft dependency (`Granit.Core`) [Section titled “Soft dependency (Granit.Core)”](#soft-dependency-granitcore) | Component | File | Role | | ------------------- | --------------------------------------------------- | ---------------------------------------------------- | | `ICurrentTenant` | `src/Granit.Core/MultiTenancy/ICurrentTenant.cs` | Minimal interface: `Id`, `IsAvailable`, `Change()` | | `NullTenantContext` | `src/Granit.Core/MultiTenancy/NullTenantContext.cs` | Null Object: `IsAvailable = false`, no-op operations | All modules resolve `ICurrentTenant` via `Granit.Core.MultiTenancy` — no `[DependsOn(GranitMultiTenancyModule)]` required. ### Hard dependency (`Granit.MultiTenancy`) [Section titled “Hard dependency (Granit.MultiTenancy)”](#hard-dependency-granitmultitenancy) | Component | File | Role | | ---------------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------------------- | | `CurrentTenant` | `src/Granit.MultiTenancy/CurrentTenant.cs` | `AsyncLocal` implementation + `TenantScope` (IDisposable) | | `TenantResolverPipeline` | `src/Granit.MultiTenancy/Pipeline/TenantResolverPipeline.cs` | Ordered chain of `ITenantResolver` | | `HeaderTenantResolver` | `src/Granit.MultiTenancy/Resolvers/HeaderTenantResolver.cs` | Resolution via `X-Tenant-Id` (priority 100) | | `JwtClaimTenantResolver` | `src/Granit.MultiTenancy/Resolvers/JwtClaimTenantResolver.cs` | Resolution via JWT claim (priority 200) | | `TenantResolutionMiddleware` | `src/Granit.MultiTenancy/Middleware/TenantResolutionMiddleware.cs` | ASP.NET Core middleware | ### Isolation strategies (`Granit.Persistence`) [Section titled “Isolation strategies (Granit.Persistence)”](#isolation-strategies-granitpersistence) | Strategy | File | Mechanism | | ------------------------- | -------------------------------------------------------------------------- | --------------------------------------------- | | `SharedDatabase` | `src/Granit.Persistence/MultiTenancy/SharedDatabaseDbContextFactory.cs` | EF Core query filters on `TenantId` | | `SchemaPerTenant` | `src/Granit.Persistence/MultiTenancy/TenantPerSchemaDbContextFactory.cs` | `SET search_path TO tenant_{id}` (PostgreSQL) | | `DatabasePerTenant` | `src/Granit.Persistence/MultiTenancy/TenantPerDatabaseDbContextFactory.cs` | Dedicated connection string per tenant | | `TenantIsolationStrategy` | `src/Granit.Persistence/MultiTenancy/TenantIsolationStrategy.cs` | Selection enum | ### Async propagation (`Granit.Wolverine`) [Section titled “Async propagation (Granit.Wolverine)”](#async-propagation-granitwolverine) | Component | File | Role | | --------------------------- | -------------------------------------------------------------- | ------------------------------------------------------- | | `OutgoingContextMiddleware` | `src/Granit.Wolverine/Middleware/OutgoingContextMiddleware.cs` | Injects `X-Tenant-Id` into outgoing Wolverine envelopes | | `TenantContextBehavior` | `src/Granit.Wolverine/Behaviors/TenantContextBehavior.cs` | Restores `ICurrentTenant` in background handlers | ### The `IsAvailable` check rule [Section titled “The IsAvailable check rule”](#the-isavailable-check-rule) Any code accessing `ICurrentTenant.Id` **must** check `IsAvailable` first: ```csharp // Correct pattern (src/Granit.Features/Checker/FeatureChecker.cs:46) Guid? tenantId = currentTenant?.IsAvailable == true ? currentTenant.Id : null; ``` ## Rationale [Section titled “Rationale”](#rationale) | Problem | Solution | | ----------------------------------------------------------------------- | -------------------------------------------------------------------- | | GDPR/ISO 27001: strict data isolation per organization | 3 isolation strategies cover all cases (cost vs security) | | Modules that read the tenant without depending on `Granit.MultiTenancy` | Soft dependency via `Granit.Core.MultiTenancy` + `NullTenantContext` | | Loss of tenant context in asynchronous processing | Propagation via Wolverine headers + restoration by behaviors | | Need to temporarily switch tenant (cross-tenant admin) | `ICurrentTenant.Change()` returns an `IDisposable` scope | ## Usage example [Section titled “Usage example”](#usage-example) ```csharp // Shared Database: query filters apply automatically public sealed class PatientService(AppDbContext db, ICurrentTenant tenant) { public async Task> GetAllAsync(CancellationToken cancellationToken) { // EF Core automatically adds WHERE TenantId = @currentTenantId List patients = await db.Patients.ToListAsync(ct); return patients; } } // Temporary tenant switch (admin operation) public async Task MigrateTenantDataAsync( Guid sourceTenantId, Guid targetTenantId, ICurrentTenant currentTenant, CancellationToken cancellationToken) { using (currentTenant.Change(sourceTenantId)) { // Read in the source tenant context List patients = await db.Patients.ToListAsync(ct); } // The previous tenant is automatically restored here } ``` ## Further reading [Section titled “Further reading”](#further-reading) * [Architect Multitenant Solutions on Azure — Microsoft Azure Architecture Center](https://learn.microsoft.com/en-us/azure/architecture/guide/multitenant/overview) # Null Object > No-op implementations that eliminate null checks across the Granit module system ## Definition [Section titled “Definition”](#definition) The Null Object pattern replaces `null` checks with an object that implements the expected interface with neutral (no-op) behavior. Calling code treats the Null Object like any other implementation, eliminating conditional branches. ## Diagram [Section titled “Diagram”](#diagram) ``` classDiagram class ICurrentTenant { +Id : Guid? +IsAvailable : bool +Change(id) IDisposable } class CurrentTenant { +Id : Guid? +IsAvailable : bool = true +Change(id) IDisposable } class NullTenantContext { +Id : Guid? = null +IsAvailable : bool = false +Change(id) IDisposable = no-op +Instance : NullTenantContext } ICurrentTenant <|.. CurrentTenant ICurrentTenant <|.. NullTenantContext ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) | Null Object | File | Interface | Behavior | | -------------------------- | ---------------------------------------------------------- | ----------------------- | --------------------------------------------------------- | | `NullTenantContext` | `src/Granit.Core/MultiTenancy/NullTenantContext.cs` | `ICurrentTenant` | `IsAvailable = false`, `Id = null`, `Change()` is a no-op | | `NullCacheValueEncryptor` | `src/Granit.Caching/NullCacheValueEncryptor.cs` | `ICacheValueEncryptor` | Passes bytes through without encryption (dev) | | `NullWebhookDeliveryStore` | `src/Granit.Webhooks/Internal/NullWebhookDeliveryStore.cs` | `IWebhookDeliveryStore` | No-op operations | `NullTenantContext` is registered by default in the DI container. It is replaced by `CurrentTenant` only when `Granit.MultiTenancy` is installed. This is the **soft dependency**: all modules can inject `ICurrentTenant` without depending on `Granit.MultiTenancy`. ## Rationale [Section titled “Rationale”](#rationale) Without the Null Object, every module would have to check whether `ICurrentTenant` is `null` or whether multi-tenancy is installed. With `NullTenantContext`, code simply checks `IsAvailable` — never `null`. ## Usage example [Section titled “Usage example”](#usage-example) ```csharp // Code works identically with or without multi-tenancy public sealed class FeatureChecker(IServiceProvider sp) { public async Task GetValueAsync(string featureName, CancellationToken cancellationToken) { ICurrentTenant? currentTenant = sp.GetService(); // NullTenantContext: IsAvailable = false -> tenantId = null // CurrentTenant: IsAvailable = true -> tenantId = Guid Guid? tenantId = currentTenant?.IsAvailable == true ? currentTenant.Id : null; // The rest of the code is identical in both cases return await ResolveFeatureValueAsync(featureName, tenantId, ct); } } ``` ## Further reading [Section titled “Further reading”](#further-reading) * [Null Object Design Pattern — sourcemaking.com](https://sourcemaking.com/design_patterns/null_object) # Observer / Event > Implicit event subscription via Wolverine handler discovery in Granit ## Definition [Section titled “Definition”](#definition) The Observer pattern establishes a one-to-many relationship between objects: when an object (subject) changes state, all its observers are notified automatically. In Granit, Wolverine acts as the subscription mechanism — handlers are discovered by convention and implicitly subscribe to the message types they process. ## Diagram [Section titled “Diagram”](#diagram) ``` sequenceDiagram participant P as Publisher participant W as Wolverine Bus participant H1 as FeatureCacheInvalidationHandler participant H2 as AuditLogHandler participant H3 as WebhookFanoutHandler P->>W: Publish FeatureValueChangedEvent W->>H1: HandleAsync() -- purge cache W->>H2: HandleAsync() -- audit log P->>W: Publish WebhookTrigger W->>H3: HandleAsync() -- fan-out to subscriptions ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) ### Implicit Wolverine subscription [Section titled “Implicit Wolverine subscription”](#implicit-wolverine-subscription) Wolverine automatically discovers handlers by naming convention. A handler that accepts a parameter of type `T` implicitly subscribes to all messages of type `T`. | Handler (observer) | Event (subject) | File | | --------------------------------- | -------------------------- | -------------------------------------------------------------- | | `FeatureCacheInvalidationHandler` | `FeatureValueChangedEvent` | `src/Granit.Features/Cache/FeatureCacheInvalidationHandler.cs` | | `WebhookFanoutHandler` | `WebhookTrigger` | `src/Granit.Webhooks/Handlers/WebhookFanoutHandler.cs` | ### Sidecar pattern (implicit return) [Section titled “Sidecar pattern (implicit return)”](#sidecar-pattern-implicit-return) Wolverine handlers can return events via `yield return` or `IEnumerable`. Wolverine automatically dispatches these events to registered observers. ## Rationale [Section titled “Rationale”](#rationale) Implicit observation via Wolverine eliminates coupling between the publisher and observers. The publisher does not know how many observers exist or what they do. Adding a new observer requires no modification to the publisher. ## Usage example [Section titled “Usage example”](#usage-example) ```csharp // Publisher -- knows no observers public static class UpdateFeatureValueHandler { public static IEnumerable Handle( UpdateFeatureValueCommand command, IFeatureStore store) { store.SetAsync(command.TenantId, command.FeatureName, command.Value); // Wolverine dispatches this event to all registered handlers yield return new FeatureValueChangedEvent { TenantId = command.TenantId, FeatureName = command.FeatureName }; } } // Observer 1 -- discovered automatically public static class FeatureCacheInvalidationHandler { public static async Task Handle( FeatureValueChangedEvent evt, HybridCache cache) { string cacheKey = FeatureCacheKey.Build(evt.TenantId, evt.FeatureName); await cache.RemoveAsync(cacheKey); } } // Observer 2 -- added later, no modification to publisher public static class FeatureAuditLogHandler { public static void Handle(FeatureValueChangedEvent evt, ILogger logger) { logger.LogInformation("[AUDIT] Feature {Feature} changed for tenant {Tenant}", evt.FeatureName, evt.TenantId); } } ``` ## Further reading [Section titled “Further reading”](#further-reading) * [Observer — refactoring.guru](https://refactoring.guru/design-patterns/observer) * [Publisher-Subscriber — Microsoft Cloud Design Patterns](https://learn.microsoft.com/en-us/azure/architecture/patterns/publisher-subscriber) # Options Pattern > How every Granit module structures configuration as strongly-typed, startup-validated options classes ## Definition [Section titled “Definition”](#definition) The Options pattern structures application configuration as strongly-typed classes validated at startup. Each Granit module exposes an `*Options` class bound to an `appsettings.json` section via `BindConfiguration`, with validation through `DataAnnotations` and `ValidateOnStart()`. ## Diagram [Section titled “Diagram”](#diagram) ``` flowchart LR subgraph Config["appsettings.json"] JSON["Vault: Address: ..."] end subgraph Registration["DI Extension"] Bind["AddOptions of VaultOptions
.BindConfiguration(SectionName)
.ValidateDataAnnotations()
.ValidateOnStart()"] end subgraph Runtime["Injection"] IO["IOptions of VaultOptions"] IOM["IOptionsMonitor of VaultOptions"] end JSON --> Bind --> IO Bind --> IOM style Config fill:#f5f5f5,stroke:#666 style Registration fill:#e8f4fd,stroke:#1a73e8 style Runtime fill:#e8fde8,stroke:#2d8a4e ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) ### Convention [Section titled “Convention”](#convention) Each Options class follows this template: ```csharp public sealed class VaultOptions { public const string SectionName = "Vault"; [Required] public string Address { get; set; } = string.Empty; public string AuthMethod { get; set; } = "kubernetes"; [Range(0.1, 1.0)] public double LeaseRenewalThreshold { get; set; } = 0.75; } ``` Registration in the module’s DI extension: ```csharp services.AddOptions() .BindConfiguration(VaultOptions.SectionName) .ValidateDataAnnotations() .ValidateOnStart(); ``` ### Inventory (excerpt — 93 options classes in the framework) [Section titled “Inventory (excerpt — 93 options classes in the framework)”](#inventory-excerpt--93-options-classes-in-the-framework) | Module | Class | Section | Notable validation | | ------------------- | ---------------------- | ----------------------------- | -------------------------------------------------------- | | Vault | `VaultOptions` | `Vault` | `[Required]` Address | | Caching | `CachingOptions` | `Cache` | KeyPrefix, EncryptValues | | Observability | `ObservabilityOptions` | `Observability` | ServiceName, OtlpEndpoint | | Identity.Keycloak | `KeycloakAdminOptions` | `KeycloakAdmin` | `[Required]` + `[Range]` + URL helpers | | Auth.JwtBearer | `JwtBearerAuthOptions` | `Authentication` | Authority, Audience | | BlobStorage.S3 | `S3BlobOptions` | inherits `BlobStorageOptions` | Custom `IValidateOptions` | | Webhooks | `WebhooksOptions` | `Webhooks` | `[Range(5, 120)]` timeout, `[Range(1, 100)]` parallelism | | Notifications | `NotificationsOptions` | `Notifications` | MaxParallelDeliveries | | Notifications.Brevo | `BrevoOptions` | `Notifications:Brevo` | `[Required]` ApiKey, `[Range(1, 300)]` timeout | | MultiTenancy | `MultiTenancyOptions` | `MultiTenancy` | IsEnabled, TenantIdClaimType | ### Advanced validation (IValidateOptions) [Section titled “Advanced validation (IValidateOptions)”](#advanced-validation-ivalidateoptions) Some modules require cross-property validation. They implement `IValidateOptions`: ```csharp internal sealed class S3BlobOptionsValidator : IValidateOptions { public ValidateOptionsResult Validate(string? name, S3BlobOptions options) { if (string.IsNullOrWhiteSpace(options.ServiceUrl)) return ValidateOptionsResult.Fail("S3 ServiceUrl is required."); if (options.ForcePathStyle && options.ServiceUrl.Contains("amazonaws.com")) return ValidateOptionsResult.Fail("ForcePathStyle should not be used with AWS."); return ValidateOptionsResult.Success; } } ``` ### Reference files [Section titled “Reference files”](#reference-files) | File | Role | | -------------------------------------------------------------- | ---------------------------- | | `src/Granit.Vault/Options/VaultOptions.cs` | Canonical simple example | | `src/Granit.Identity.Keycloak/Options/KeycloakAdminOptions.cs` | Complex options with helpers | | `src/Granit.BlobStorage.S3/Options/S3BlobOptions.cs` | Custom `IValidateOptions` | | `src/Granit.Webhooks/Options/WebhooksOptions.cs` | `[Range]` validation | | `src/Granit.Observability/Options/ObservabilityOptions.cs` | Observability options | ## Rationale [Section titled “Rationale”](#rationale) | Problem | Options pattern solution | | ----------------------------------------------------- | --------------------------------------------------- | | Configuration read as untyped strings | Strongly-typed classes with IntelliSense | | Config errors discovered at runtime | `ValidateOnStart()` — fail-fast at startup | | Misspelled JSON sections | `const string SectionName` = single source of truth | | Cross-property validation impossible with annotations | `IValidateOptions` for complex rules | | Plaintext secrets in appsettings | Compatible with Vault, User Secrets, env vars | ## Usage example [Section titled “Usage example”](#usage-example) ```csharp // --- Registration in the module's DI extension --- public static IServiceCollection AddGranitWebhooks( this IServiceCollection services) { services.AddOptions() .BindConfiguration(WebhooksOptions.SectionName) .ValidateDataAnnotations() .ValidateOnStart(); services.AddSingleton, WebhooksOptionsValidator>(); return services; } // --- Consumption in a service --- public sealed class WebhookDeliveryService( IOptions options, IHttpClientFactory httpClientFactory) { public async Task DeliverAsync(WebhookPayload payload, CancellationToken ct) { HttpClient client = httpClientFactory.CreateClient(); client.Timeout = TimeSpan.FromSeconds(options.Value.HttpTimeoutSeconds); // ... } } ``` ## Further reading [Section titled “Further reading”](#further-reading) * [Options pattern — Microsoft .NET Documentation](https://learn.microsoft.com/en-us/dotnet/core/extensions/options) # Pre-Signed URL > Direct-to-cloud file upload/download bypassing the application server with GDPR crypto-shredding ## Definition [Section titled “Definition”](#definition) The Pre-Signed URL pattern allows clients to upload or download files directly to/from object storage (S3), without transiting through the application server. The server generates a cryptographically signed temporary URL with constraints (MIME type, max size, expiration). In Granit, this pattern is at the core of `Granit.BlobStorage` with a Direct-to-Cloud architecture, a post-upload validation pipeline, and a GDPR-compliant crypto-shredding mechanism. ## Diagram [Section titled “Diagram”](#diagram) ``` sequenceDiagram participant C as Client participant API as Granit API participant S3 as S3 in Europe participant V as Validation Pipeline participant DB as BlobDescriptorStore Note over C,DB: Phase 1 -- Initiation C->>API: InitiateUploadAsync("medical-docs", request) API->>DB: Create BlobDescriptor (Status = Pending) API->>S3: Generate PUT Pre-Signed URL API-->>C: PresignedUploadTicket (URL + expiry) Note over C,S3: Phase 2 -- Direct upload C->>S3: PUT {presignedUrl} [binary file] Note over C,S3: The application server is not involved Note over S3,DB: Phase 3 -- Validation S3-->>V: Notification (SNS/webhook) V->>V: MagicBytesValidator (Order=10) V->>V: MaxSizeValidator (Order=20) alt Validation passed V->>DB: Status = Valid else Validation failed V->>DB: Status = Rejected end Note over C,DB: Phase 4 -- Download C->>API: CreateDownloadUrlAsync("medical-docs", blobId) API->>DB: Check Status = Valid API->>S3: Generate GET Pre-Signed URL API-->>C: PresignedDownloadUrl (URL + expiry) C->>S3: GET {presignedUrl} ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) ### Main components [Section titled “Main components”](#main-components) | Component | File | Role | | ----------------------- | ------------------------------------------------------- | -------------------------------------------------------------------------- | | `IBlobStorage` | `src/Granit.BlobStorage/IBlobStorage.cs` | Public API: `InitiateUploadAsync`, `CreateDownloadUrlAsync`, `DeleteAsync` | | `DefaultBlobStorage` | `src/Granit.BlobStorage/Internal/DefaultBlobStorage.cs` | Facade orchestrating all components | | `BlobDescriptor` | `src/Granit.BlobStorage/BlobDescriptor.cs` | Entity: status, metadata, audit trail | | `BlobStatus` | `src/Granit.BlobStorage/BlobStatus.cs` | State machine: Pending > Uploading > Valid/Rejected > Deleted | | `PresignedUploadTicket` | `src/Granit.BlobStorage/PresignedUploadTicket.cs` | DTO: BlobId, URL, HttpMethod, Expiry, RequiredHeaders | | `PresignedDownloadUrl` | `src/Granit.BlobStorage/PresignedDownloadUrl.cs` | DTO: URL + expiry | ### Multi-tenant S3 key [Section titled “Multi-tenant S3 key”](#multi-tenant-s3-key) | Component | File | Role | | ----------------------- | ------------------------------------------------------------- | ----------------------------------------------------- | | `IBlobKeyStrategy` | `src/Granit.BlobStorage/IBlobKeyStrategy.cs` | Object key generation and parsing | | `PrefixBlobKeyStrategy` | `src/Granit.BlobStorage.S3/Internal/PrefixBlobKeyStrategy.cs` | Format: `{tenantId}/{container}/{yyyy}/{MM}/{blobId}` | ### Post-upload validation [Section titled “Post-upload validation”](#post-upload-validation) | Validator | File | Order | Role | | --------------------- | ---------------------------------------------------------- | ----- | --------------------------------------- | | `MagicBytesValidator` | `src/Granit.BlobStorage/Validators/MagicBytesValidator.cs` | 10 | Verifies actual MIME type (magic bytes) | | `MaxSizeValidator` | `src/Granit.BlobStorage/Validators/MaxSizeValidator.cs` | 20 | Verifies declared vs actual size | ### Crypto-shredding (GDPR Art. 17) [Section titled “Crypto-shredding (GDPR Art. 17)”](#crypto-shredding-gdpr-art-17) `DefaultBlobStorage.DeleteAsync()`: 1. **Physically deletes** the S3 object (`storageClient.DeleteObjectAsync`) 2. Marks the `BlobDescriptor` as `Deleted` (soft delete) 3. Retains the metadata in DB for the **ISO 27001 audit trail** (3 years) ## Rationale [Section titled “Rationale”](#rationale) | Problem | Solution | | ------------------------------------------------------------- | -------------------------------------------------------------------- | | Large medical files (MRI, scans) saturate the server | Direct upload client to S3, server never sees the binary | | Client-side MIME type validation is unreliable | Server-side post-upload validation via magic bytes | | GDPR right to erasure + ISO 27001 audit trail (contradictory) | Crypto-shredding: binary destroyed, metadata retained in soft delete | | Multi-tenant isolation in the S3 bucket | Key prefixed with `tenantId` + `TryExtractTenantId()` check | | Security: the client must not have S3 credentials | Pre-signed URL with short expiration, MIME type constraints | ## Usage example [Section titled “Usage example”](#usage-example) ```csharp // Upload a medical document public sealed class UploadMedicalDocumentHandler { public static async Task Handle( UploadDocumentCommand command, IBlobStorage blobStorage, CancellationToken cancellationToken) { PresignedUploadTicket ticket = await blobStorage.InitiateUploadAsync( containerName: "medical-documents", new BlobUploadRequest( FileName: command.FileName, ContentType: "application/pdf", MaxAllowedBytes: 50_000_000), // 50 MB ct); // The client uses ticket.UploadUrl to upload directly to S3 return ticket; } } // GDPR-compliant deletion (crypto-shredding) await blobStorage.DeleteAsync( containerName: "medical-documents", blobId: documentId, deletionReason: "GDPR Art. 17 -- patient request", cancellationToken); // -> S3 object physically deleted // -> BlobDescriptor retained in DB (IsDeleted=true, DeletedBy, DeletedAt, DeletionReason) ``` ## Further reading [Section titled “Further reading”](#further-reading) * [Valet Key pattern — Microsoft Cloud Design Patterns](https://learn.microsoft.com/en-us/azure/architecture/patterns/valet-key) # Proxy > EF Core interceptors and query filter proxies for transparent audit and soft delete in Granit ## Definition [Section titled “Definition”](#definition) The Proxy pattern provides a substitute or intermediary that controls access to an object. The proxy intercepts calls to add behavior (audit, filtering, validation) transparently. In Granit, two Proxy variants are used: the **FilterProxy** for EF Core query filters, and **EF Core Interceptors** that intercept `SaveChangesAsync()`. ## Diagram [Section titled “Diagram”](#diagram) ``` sequenceDiagram participant App as Application participant DB as DbContext participant AI as AuditedEntityInterceptor participant SDI as SoftDeleteInterceptor participant EF as EF Core Engine App->>DB: SaveChangesAsync() DB->>AI: SavingChangesAsync() AI->>AI: Iterate ChangeTracker entries AI->>AI: Added: CreatedAt, CreatedBy, TenantId AI->>AI: Modified: ModifiedAt, ModifiedBy DB->>SDI: SavingChangesAsync() SDI->>SDI: Deleted becomes Modified (IsDeleted=true) SDI->>SDI: DeletedAt, DeletedBy DB->>EF: Execute SQL queries ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) ### FilterProxy (property proxy for EF Core) [Section titled “FilterProxy (property proxy for EF Core)”](#filterproxy-property-proxy-for-ef-core) | Component | File | Role | | ------------- | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `FilterProxy` | `src/Granit.Persistence/Extensions/ModelBuilderExtensions.cs` (lines 133-140) | Exposes boolean properties (`SoftDeleteEnabled`, `ActiveEnabled`, `MultiTenantEnabled`) so EF Core can extract them as query parameters | EF Core cannot translate arbitrary method calls in query filters. The `FilterProxy` works around this limitation by exposing simple properties that EF Core treats as SQL parameters. ### EF Core Interceptors [Section titled “EF Core Interceptors”](#ef-core-interceptors) | Interceptor | File | Role | | -------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------- | | `AuditedEntityInterceptor` | `src/Granit.Persistence/Interceptors/AuditedEntityInterceptor.cs` | Injects `CreatedAt/By`, `ModifiedAt/By`, `TenantId`, `Id` (sequential) | | `SoftDeleteInterceptor` | `src/Granit.Persistence/Interceptors/SoftDeleteInterceptor.cs` | Converts `DELETE` to `UPDATE` with `IsDeleted=true`, `DeletedAt/By` | ## Rationale [Section titled “Rationale”](#rationale) EF Core interceptors apply cross-cutting rules (ISO 27001 audit, GDPR soft delete) transparently without polluting application handlers. The `FilterProxy` solves a technical EF Core limitation while keeping filters dynamic. ## Usage example [Section titled “Usage example”](#usage-example) ```csharp // The application never sees the interceptors -- they are transparent Patient patient = new() { FirstName = "Jean", LastName = "Dupont" }; db.Patients.Add(patient); await db.SaveChangesAsync(ct); // AuditedEntityInterceptor has automatically populated: // patient.Id = Sequential Guid (IGuidGenerator) // patient.CreatedAt = DateTimeOffset.UtcNow (IClock) // patient.CreatedBy = "user-123" (ICurrentUserService) // patient.TenantId = Current tenant Guid (ICurrentTenant) // Deletion is intercepted by SoftDeleteInterceptor: db.Patients.Remove(patient); await db.SaveChangesAsync(ct); // -> UPDATE Patients SET IsDeleted=1, DeletedAt=..., DeletedBy=... WHERE Id=... // -> No physical DELETE ``` ## Further reading [Section titled “Further reading”](#further-reading) * [Proxy — refactoring.guru](https://refactoring.guru/design-patterns/proxy) # Rate Limiting > Per-tenant throttling with Redis Lua scripts, dynamic quotas, and dual HTTP/messaging integration ## Definition [Section titled “Definition”](#definition) **Rate Limiting** controls the number of requests a client can send within a given time window. In a multi-tenant SaaS context, it protects against the **noisy neighbor** problem — a greedy tenant degrading performance for everyone else. Granit implements this pattern via `Granit.RateLimiting` with per-tenant partitioning, atomic Redis counters (Lua scripts), and dynamic quotas linked to pricing plans via `Granit.Features`. ## Diagram [Section titled “Diagram”](#diagram) ``` flowchart LR R[HTTP Request] --> F{Bypass?} F -- Admin role --> A[Allowed] F -- No --> T[Tenant resolution] T --> Q[Quota resolution] Q --> C{Redis counter} C -- within limit --> A C -- over limit --> D[429 Too Many Requests] D --> RA[Retry-After header] ``` ``` sequenceDiagram participant Client participant Filter as Endpoint Filter participant Limiter as TenantPartitionedRateLimiter participant Redis Client->>Filter: GET /api/patients Filter->>Limiter: CheckAsync("api") Limiter->>Redis: EVALSHA sliding_window.lua Redis-->>Limiter: count: 42, oldest: 0 Limiter-->>Filter: Allowed (remaining: 58) Filter-->>Client: 200 OK Note over Client,Redis: After 100 requests in 60s... Client->>Filter: GET /api/patients Filter->>Limiter: CheckAsync("api") Limiter->>Redis: EVALSHA sliding_window.lua Redis-->>Limiter: count: 101, oldest: 18000 Limiter-->>Filter: Rejected (retryAfter: 18s) Filter-->>Client: 429 + Retry-After: 18 ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) ### Package [Section titled “Package”](#package) | Package | Role | | --------------------- | ------------------------------------------------------- | | `Granit.RateLimiting` | Complete module: counters, middleware, options, metrics | ### Three algorithms via Lua scripts [Section titled “Three algorithms via Lua scripts”](#three-algorithms-via-lua-scripts) Each algorithm is implemented as a Lua script executed atomically by Redis (`EVALSHA`). Timestamps are taken server-side (`redis.call('TIME')`) to avoid clock drift issues between pods. | Algorithm | Redis structure | Use case | | ------------------ | ---------------------------------------- | --------------------------------- | | **Sliding Window** | Sorted set (`ZADD` + `ZREMRANGEBYSCORE`) | Public APIs — maximum precision | | **Fixed Window** | Counter (`INCR` + `PEXPIRE`) | Low-volume endpoints — simplicity | | **Token Bucket** | Hash (`HMGET`/`HSET` + refill) | Export jobs — controlled bursts | ### Per-tenant partitioning [Section titled “Per-tenant partitioning”](#per-tenant-partitioning) The Redis key is structured with a **hash tag** to guarantee co-location in Redis Cluster: ```text {prefix}:{tenantId}:{policyName} rl :{a1b2c3d4}: api ``` Without multi-tenancy, the `global` segment is used. Each tenant has its own counters — a tenant can never consume another’s quota. ### Dynamic quotas by plan [Section titled “Dynamic quotas by plan”](#dynamic-quotas-by-plan) When `UseFeatureBasedQuotas` is enabled, the `PermitLimit` is resolved dynamically from `Granit.Features` instead of static configuration: ```csharp // Convention: Numeric feature named "RateLimit.{policyName}" context.Add( new FeatureDefinition("RateLimit.api", FeatureValueType.Numeric(100, 10, 10000)) ); ``` The Features resolution chain (Default > Plan > Tenant) enables differentiated quotas: | Plan | RateLimit.api | RateLimit.export | | ---------- | ------------- | ---------------- | | Free | 60/min | 5/h | | Pro | 500/min | 50/h | | Enterprise | 5000/min | Unlimited | ### Dual integration: HTTP + Messaging [Section titled “Dual integration: HTTP + Messaging”](#dual-integration-http--messaging) ```csharp // --- ASP.NET Core: endpoint filter --- app.MapGet("/api/v1/patients", GetPatientsAsync) .RequireGranitRateLimiting("api"); // --- Wolverine: attribute on the message --- [RateLimited("export")] public sealed record GeneratePatientExportCommand(Guid PatientId); ``` The HTTP filter returns `429 Too Many Requests` (RFC 7807) with a `Retry-After` header. The Wolverine middleware throws `RateLimitExceededException`, usable with `RetryWithCooldown`. ### Graceful degradation [Section titled “Graceful degradation”](#graceful-degradation) When Redis is unavailable, the behavior is configurable: | Mode | Behavior | When to use | | ----------------- | ------------------------- | ---------------------------------- | | `Allow` (default) | Request allowed + warning | Availability > quota protection | | `Deny` | Systematic 429 | Critical endpoints (payment, auth) | ### Reference files [Section titled “Reference files”](#reference-files) | File | Role | | ------------------------------------------------------------------------ | ------------------------------------------- | | `src/Granit.RateLimiting/Internal/LuaScripts.cs` | 3 atomic Lua scripts | | `src/Granit.RateLimiting/Internal/TenantPartitionedRateLimiter.cs` | Core logic (tenant, bypass, quota, metrics) | | `src/Granit.RateLimiting/Internal/RedisRateLimitCounterStore.cs` | Redis execution with fallback | | `src/Granit.RateLimiting/Internal/FeatureBasedRateLimitQuotaProvider.cs` | Quota resolution via Granit.Features | | `src/Granit.RateLimiting/AspNetCore/RateLimitEndpointExtensions.cs` | Endpoint filter 429 + Retry-After | | `src/Granit.RateLimiting/Wolverine/RateLimitMiddleware.cs` | Wolverine BeforeAsync middleware | ## Rationale [Section titled “Rationale”](#rationale) | Problem | Solution | | ------------------------------------------------------------- | ------------------------------------------------------- | | Greedy tenant saturates the API for everyone (noisy neighbor) | Counters partitioned by tenant, independent quotas | | Identical quota limits for all plans | `Granit.Features` Numeric resolves dynamically by plan | | Redis failure = blocked service | Configurable graceful degradation (Allow/Deny) | | Clock drift between pods = inconsistent counters | `redis.call('TIME')` in Lua scripts | | Rate limiting HTTP but not messaging | Dual integration endpoint filter + Wolverine middleware | | Admin blocked by their own rate limiting | Configurable `BypassRoles` | ## Usage example [Section titled “Usage example”](#usage-example) ```csharp // --- appsettings.json --- // { // "RateLimiting": { // "BypassRoles": ["Admin"], // "UseFeatureBasedQuotas": true, // "Policies": { // "api": { "Algorithm": "SlidingWindow", "PermitLimit": 100, "Window": "00:01:00" }, // "auth": { "Algorithm": "FixedWindow", "PermitLimit": 5, "Window": "00:15:00" } // } // } // } // --- Module registration --- [DependsOn(typeof(GranitRateLimitingModule))] public sealed class AppModule : GranitModule { } // --- Applying policies --- app.MapGet("/api/v1/appointments", ListAppointmentsAsync) .RequireGranitRateLimiting("api"); app.MapPost("/api/v1/auth/login", LoginAsync) .RequireGranitRateLimiting("auth"); // 5 attempts / 15 min ``` ## Further reading [Section titled “Further reading”](#further-reading) * [Rate Limiting pattern — Microsoft Cloud Design Patterns](https://learn.microsoft.com/en-us/azure/architecture/patterns/rate-limiting-pattern) * [Throttling pattern — Microsoft Cloud Design Patterns](https://learn.microsoft.com/en-us/azure/architecture/patterns/throttling) # Repository (Store) > How Granit abstracts data access behind CQRS Reader/Writer store interfaces ## Definition [Section titled “Definition”](#definition) The Repository pattern abstracts data access behind a collection-like interface, isolating business logic from persistence details. In Granit, repositories are named **Stores** and provide interchangeable implementations (InMemory, EF Core, Redis). ## Diagram [Section titled “Diagram”](#diagram) ``` classDiagram class IBlobDescriptorStoreReader { <> +FindAsync(blobId) BlobDescriptor? } class IBlobDescriptorStoreWriter { <> +SaveAsync(descriptor) +UpdateAsync(descriptor) } class IFeatureStoreReader { <> +GetOrNullAsync(tenantId, name) string? } class IFeatureStoreWriter { <> +SetAsync(tenantId, name, value) +DeleteAsync(tenantId, name) } class ISettingStoreReader { <> +GetOrNullAsync(name, scope) string? +GetListAsync(scope) List } class ISettingStoreWriter { <> +SetAsync(name, value, scope) +DeleteAsync(name, scope) } class EfBlobDescriptorStore class InMemoryFeatureStore class EfCoreFeatureStore class EfCoreSettingStore IBlobDescriptorStoreReader <|.. EfBlobDescriptorStore IBlobDescriptorStoreWriter <|.. EfBlobDescriptorStore IFeatureStoreReader <|.. InMemoryFeatureStore IFeatureStoreWriter <|.. InMemoryFeatureStore IFeatureStoreReader <|.. EfCoreFeatureStore IFeatureStoreWriter <|.. EfCoreFeatureStore ISettingStoreReader <|.. EfCoreSettingStore ISettingStoreWriter <|.. EfCoreSettingStore ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) | Store (port) | Location | Implementations | | --------------------------------------------------------------------- | ------------------------------------- | ---------------------------------------------------- | | `IBlobDescriptorStoreReader` / `IBlobDescriptorStoreWriter` | `src/Granit.BlobStorage/` | `EfBlobDescriptorStore` | | `IFeatureStoreReader` / `IFeatureStoreWriter` | `src/Granit.Features/Store/` | `InMemoryFeatureStore`, `EfCoreFeatureStore` | | `IBackgroundJobStoreReader` / `IBackgroundJobStoreWriter` | `src/Granit.BackgroundJobs/Internal/` | `InMemoryBackgroundJobStore`, `EfBackgroundJobStore` | | `ISettingStoreReader` / `ISettingStoreWriter` | `src/Granit.Settings/Values/` | `EfCoreSettingStore` | | `IWebhookSubscriptionStoreReader` / `IWebhookSubscriptionStoreWriter` | `src/Granit.Webhooks/Abstractions/` | `EfWebhookSubscriptionStore` | Each EF Core store uses an isolated `DbContext` (not the application DbContext) via `IDbContextFactory`. ## Rationale [Section titled “Rationale”](#rationale) The decoupling allows using `InMemoryFeatureStore` in development and `EfCoreFeatureStore` in production without changing application code. The Reader/Writer separation (CQRS) allows injecting only the required interface: read handlers only access the Reader, write handlers access the Writer. Unit tests use InMemory stores to avoid database dependencies. ## Usage example [Section titled “Usage example”](#usage-example) ```csharp // Read -- inject the Reader IFeatureStoreReader reader = serviceProvider.GetRequiredService(); string? value = await reader.GetOrNullAsync(tenantId, "MaxPatients", ct); // Write -- inject the Writer IFeatureStoreWriter writer = serviceProvider.GetRequiredService(); await writer.SetAsync(tenantId, "MaxPatients", "500", ct); ``` ## Further reading [Section titled “Further reading”](#further-reading) * [Repository — Martin Fowler (PoEAA)](https://martinfowler.com/eaaCatalog/repository.html) # REPR > Request-Endpoint-Response pattern adapted for .NET Minimal APIs without external dependencies ## Definition [Section titled “Definition”](#definition) The [REPR design pattern](https://deviq.com/design-patterns/repr-design-pattern) formalizes the separation between three distinct responsibilities in an API: 1. **Request** — a dedicated type modeling incoming data 2. **Endpoint** — a single handler processing the request 3. **Response** — a dedicated type modeling the output, decoupled from the domain model This pattern opposes monolithic MVC controllers that group dozens of unrelated actions, each with different dependencies. ## Diagram [Section titled “Diagram”](#diagram) ``` flowchart LR subgraph REPR["REPR -- per operation"] direction LR Req["Request
sealed record"] EP["Endpoint
private static method"] Res["Response
sealed record"] Req --> EP --> Res end subgraph MVC["MVC -- avoid"] direction LR C["Controller
N actions
N x M dependencies"] end style REPR fill:#f0f9f0,stroke:#2d8a4e style MVC fill:#fef0f0,stroke:#c44e4e ``` ``` sequenceDiagram participant C as HTTP Client participant MW as Middleware
(Validation, Auth, Tenant) participant EP as Endpoint Handler participant SVC as Service / Store participant DB as Database C->>MW: POST /api/tasks (TaskCreateRequest) MW->>MW: FluentValidation
JWT / RBAC MW->>EP: Validated request EP->>SVC: CreateAsync(request, ct) SVC->>DB: INSERT DB-->>SVC: Entity SVC-->>EP: TaskResponse EP-->>C: 201 Created (TaskResponse) ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) Granit adopts REPR **principles** while adapting them to native .NET Minimal APIs, without external dependencies (FastEndpoints, MediatR, Ardalis). ### Adaptation: grouping by feature [Section titled “Adaptation: grouping by feature”](#adaptation-grouping-by-feature) Strict REPR prescribes **one class per endpoint**. Granit groups handlers in a **static extensions class on `RouteGroupBuilder`**, organized by feature (read, admin, sync, etc.). This pragmatic choice reduces file count while preserving the Request / Endpoint / Response separation. | Characteristic | Strict REPR (FastEndpoints / Ardalis) | Granit | | ---------------------------------------- | ------------------------------------- | --------------------------- | | Dedicated Request/Response per operation | Yes | **Yes** | | No EF entity return | Yes | **Yes** | | One file/class per endpoint | Yes | **No** — grouped by feature | | External dependency | Yes | **No** — native Minimal API | | Testability | Via base class / harness | **Pure static methods** | ### Typical Endpoints package structure [Section titled “Typical Endpoints package structure”](#typical-endpoints-package-structure) ```text src/Granit.{Module}.Endpoints/ -- Dtos/ -- {Module}{Action}Request.cs <- Request (input body / query) -- {Module}{Action}Response.cs <- Response (output) -- Endpoints/ -- {Module}ReadEndpoints.cs <- Endpoint (read handlers) -- {Module}AdminEndpoints.cs <- Endpoint (admin handlers) -- Extensions/ -- {Module}EndpointRouteBuilderExtensions.cs <- Public entry point -- Granit{Module}EndpointsModule.cs ``` ### Request pillar [Section titled “Request pillar”](#request-pillar) A `sealed record` per write or search operation. | Binding type | Convention | | --------------- | ---------------------------------------------------------------- | | Body (POST/PUT) | `{Module}{Action}Request` — positional record or with `required` | | Query string | `{Module}ListRequest` with `[AsParameters]` | | Route | Direct primitive parameters (`Guid id`, `string code`) | Rules: * **`Request` suffix** mandatory (never `Dto`) * **Business prefix** mandatory: `WorkflowTransitionRequest`, not `TransitionRequest` (OpenAPI flattens namespaces, causing schema collisions) * **Cross-cutting types exempt** from prefix: `PagedResult(T)`, `ProblemDetails` ### Endpoint pillar [Section titled “Endpoint pillar”](#endpoint-pillar) A `private static` method in an `internal static` extensions class. | Rule | Detail | | -------------- | -------------------------------------------------------------- | | Visibility | `private static` (handler), `internal static` (mapping method) | | Async | `async Task(T)`, `ConfigureAwait(false)` in library code | | Last parameter | `CancellationToken cancellationToken` | | Return | `TypedResults.*` (never `Results.*` or `IResult`) | | Metadata | `.WithName()`, `.WithSummary()`, `.WithTags()` required | | Inline lambdas | **Forbidden** — no type inference, unreadable | ### Response pillar [Section titled “Response pillar”](#response-pillar) A `sealed record` distinct from the EF Core entity. | Rule | Detail | | ------------------- | -------------------------------------------------------------------- | | Suffix | `Response` (never `Dto`) | | Anonymous types | **Forbidden** — unnamed OpenAPI schema | | Direct EF entities | **Forbidden** — coupling DB schema to API contract | | Errors | `TypedResults.Problem()` (RFC 7807), never `BadRequest(string)` | | Multi-status return | `Results(Ok(T), NotFound)`, `Results(Created(T), ProblemHttpResult)` | ### Reference files [Section titled “Reference files”](#reference-files) | Module | File | Specificity | | ------------- | ----------------------------------------------------------------------------- | -------------------------------------- | | Identity | `src/Granit.Identity.Endpoints/Endpoints/IdentityUserCacheReadEndpoints.cs` | Full CRUD, DTOs separated in `Dtos/` | | Notifications | `src/Granit.Notifications.Endpoints/Endpoints/MobilePushTokenEndpoints.cs` | DTOs inline in the endpoint file | | ReferenceData | `src/Granit.ReferenceData.Endpoints/Endpoints/ReferenceDataAdminEndpoints.cs` | Generic `(TEntity)` endpoints | | DataExchange | `src/Granit.DataExchange.Endpoints/Endpoints/Import/ImportUploadEndpoints.cs` | `IFormFile` upload, complex validation | | Authorization | `src/Granit.Authorization.Endpoints/Endpoints/MyPermissionsEndpoints.cs` | Response-only, no Request body | ## Rationale [Section titled “Rationale”](#rationale) | Problem | REPR solution | | --------------------------------------------------------- | ---------------------------------------------------------------- | | Monolithic MVC controllers with N injected dependencies | Each handler receives only its own dependencies via parameter DI | | EF entity returned = coupling DB schema to API contract | Dedicated Response record, stable OpenAPI contract | | Opaque or unnamed OpenAPI schema | `TypedResults` + typed records = deterministic schema | | Third-party framework dependency (FastEndpoints, MediatR) | Native Minimal API, zero dependency for consumers | | File explosion (one per endpoint x N modules) | Grouping by feature in `RouteGroupBuilder` extensions | ## Usage example [Section titled “Usage example”](#usage-example) ```csharp // --- Dtos/TaskCreateRequest.cs --- /// Request to create a new task. public sealed record TaskCreateRequest(string Title, string? Description = null); // --- Dtos/TaskResponse.cs --- /// Represents a task returned by the API. public sealed record TaskResponse(Guid Id, string Title, string? Description); // --- Endpoints/TaskEndpoints.cs --- internal static class TaskEndpoints { internal static void MapTaskRoutes(this RouteGroupBuilder group) { group.MapGet("/{id:guid}", GetByIdAsync) .WithName("GetTask") .WithSummary("Returns a task by its unique identifier."); group.MapPost("/", CreateAsync) .WithName("CreateTask") .WithSummary("Creates a new task."); } private static async Task, NotFound>> GetByIdAsync( Guid id, ITaskReader reader, CancellationToken cancellationToken) { TaskResponse? task = await reader.FindAsync(id, cancellationToken) .ConfigureAwait(false); return task is not null ? TypedResults.Ok(task) : TypedResults.NotFound(); } private static async Task> CreateAsync( TaskCreateRequest request, ITaskWriter writer, CancellationToken cancellationToken) { TaskResponse created = await writer.CreateAsync(request, cancellationToken) .ConfigureAwait(false); return TypedResults.Created($"/{created.Id}", created); } } // --- Extensions/TaskEndpointRouteBuilderExtensions.cs --- public static class TaskEndpointRouteBuilderExtensions { public static RouteGroupBuilder MapTaskEndpoints( this IEndpointRouteBuilder endpoints) { RouteGroupBuilder group = endpoints .MapGroup("/api/tasks") .WithTags("Tasks") .RequireAuthorization(); group.MapTaskRoutes(); return group; } } ``` ## Further reading [Section titled “Further reading”](#further-reading) * [REPR Design Pattern — deviq.com (Ardalis)](https://deviq.com/design-patterns/repr-design-pattern) # Saga / Process Manager > Multi-step process orchestration with Wolverine sagas, import/export pipelines, and workflow FSM ## Definition [Section titled “Definition”](#definition) The **Saga** (or Process Manager) orchestrates a business process composed of multiple distributed steps, each of which can fail independently. Unlike a classic ACID transaction, the saga maintains persistent intermediate state and manages compensations or timeouts. Granit implements this pattern via Wolverine Sagas (correlation + persistence) and custom orchestrators for import/export pipelines. ## Diagram [Section titled “Diagram”](#diagram) ``` stateDiagram-v2 [*] --> Started : Triggering event Started --> InProgress : Dispatch to providers InProgress --> InProgress : Fragment received InProgress --> Completed : All fragments received InProgress --> Partial : Timeout expired Completed --> [*] Partial --> [*] ``` ``` sequenceDiagram participant App participant Saga as GdprExportSaga participant P1 as Provider A participant P2 as Provider B participant Blob as BlobStorage App->>Saga: PersonalDataRequestedEvent Saga->>P1: Request data Saga->>P2: Request data Saga->>Saga: Schedule timeout (30 min) P1->>Blob: Upload fragment P1->>Saga: PersonalDataPreparedEvent P2->>Blob: Upload fragment P2->>Saga: PersonalDataPreparedEvent Saga->>App: ExportCompletedEvent (complete) ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) Granit uses the Saga / Process Manager pattern in 4 distinct contexts: ### 1. GdprExportSaga — scatter-gather (Wolverine Saga) [Section titled “1. GdprExportSaga — scatter-gather (Wolverine Saga)”](#1-gdprexportsaga--scatter-gather-wolverine-saga) GDPR Article 15/20 orchestration (right of access/portability). Collects personal data fragments from multiple providers, with configurable timeout. | Element | Detail | | --------------- | -------------------------------------------------------- | | Class | `GdprExportSaga` (extends `Saga`) | | Package | `Granit.Privacy` | | Persisted state | `ExpectedCount`, `ReceivedFragments`, `PendingProviders` | | Correlation | `RequestId` (Guid) | | Timeout | Wolverine scheduled message (`ExportTimedOutEvent`) | ```csharp // Saga start -- dispatches to all registered providers public async Task StartAsync( PersonalDataRequestedEvent @event, IDataProviderRegistry registry, IOptions options, IMessageContext context) { Id = @event.RequestId; UserId = @event.UserId; ExpectedCount = registry.Count; // Schedule timeout -- in case not all providers respond await context.ScheduleAsync( new ExportTimedOutEvent(Id), TimeSpan.FromMinutes(options.Value.ExportTimeoutMinutes)) .ConfigureAwait(false); return ExpectedCount == 0 ? new ExportCompletedEvent(Id, [], IsPartial: false) : null; } ``` **ISO 27001 compliance**: only `BlobReferenceId` values are stored in the saga state — no raw personal data transits or persists. ### 2. EfImportOrchestrator — processing pipeline [Section titled “2. EfImportOrchestrator — processing pipeline”](#2-efimportorchestrator--processing-pipeline) Import pipeline orchestration: Load > Parse > Map > Validate > Resolve Identity > Execute. Uses `IAsyncEnumerable` for streaming and persists state via `ImportJob` in the database. | Element | Detail | | --------------- | ----------------------------------------- | | Class | `EfImportOrchestrator` | | Package | `Granit.DataExchange.EntityFrameworkCore` | | Persisted state | `ImportJob` (Status, ReportJson) | | Modes | Execute (commit) / DryRun (rollback) | ### 3. ExportOrchestrator — async export [Section titled “3. ExportOrchestrator — async export”](#3-exportorchestrator--async-export) Export orchestration: definition resolution > data streaming > field projection > file writing > blob storage. The job is dispatched in background via Wolverine. | Element | Detail | | --------------- | --------------------------------------------- | | Class | `ExportOrchestrator` | | Package | `Granit.DataExchange` | | Persisted state | `ExportJob` (Status, BlobReference, RowCount) | | Dispatch | Wolverine background message | ### 4. WorkflowManager — state machine with approval [Section titled “4. WorkflowManager — state machine with approval”](#4-workflowmanager--state-machine-with-approval) Business workflow orchestration with transitions, permissions, and routing to a `PendingReview` state when approval is required. | Element | Detail | | -------- | --------------------------------------------------------------- | | Class | `WorkflowManager(TState)` | | Package | `Granit.Workflow` | | State | Enum `TState` (finite state machine) | | Outcomes | `Completed`, `ApprovalRequested`, `Denied`, `InvalidTransition` | ### Reference files [Section titled “Reference files”](#reference-files) | File | Role | | ---------------------------------------------------------------------------------------------- | ------------------------ | | `src/Granit.Privacy/DataExport/GdprExportSaga.cs` | GDPR scatter-gather saga | | `src/Granit.Privacy/DataExport/GdprExportSagaState.cs` | Saga state | | `src/Granit.DataExchange.EntityFrameworkCore/Internal/Import/Pipeline/EfImportOrchestrator.cs` | Import pipeline | | `src/Granit.DataExchange/Export/Internal/ExportOrchestrator.cs` | Export pipeline | | `src/Granit.Workflow/WorkflowManager.cs` | FSM with approval | ## Rationale [Section titled “Rationale”](#rationale) | Problem | Solution | | -------------------------------------------------------- | ------------------------------------------------------- | | Multi-provider GDPR export — some providers slow or down | Configurable timeout + partial result | | 100k-row CSV import = memory pressure | `IAsyncEnumerable` streaming, no full load | | Large export blocks the HTTP request | Background dispatch + polling by job ID | | Workflow with approval — logic scattered | Centralized FSM with automatic routing to PendingReview | | Personal data in saga state | Only `BlobReferenceId` values persisted (ISO 27001) | ## Usage example [Section titled “Usage example”](#usage-example) ```csharp // --- Trigger a GDPR export --- await messageBus.PublishAsync( new PersonalDataRequestedEvent( RequestId: Guid.NewGuid(), UserId: patient.Id), cancellationToken).ConfigureAwait(false); // The GdprExportSaga collects fragments from each provider. // When all fragments are received (or timeout): // -> ExportCompletedEvent { BlobReferences, IsPartial } // --- Trigger an import --- Guid jobId = await importOrchestrator .ExecuteAsync(importJobId, cancellationToken) .ConfigureAwait(false); // ImportJob.Status transitions from Executing -> Completed/Failed // --- Workflow transition --- TransitionResult result = await workflowManager .TransitionAsync( InvoiceStatus.Draft, InvoiceStatus.Approved, new TransitionContext("Accounting validation"), cancellationToken) .ConfigureAwait(false); // result.Outcome = Completed | ApprovalRequested | Denied ``` ## Further reading [Section titled “Further reading”](#further-reading) * [Saga pattern — Microsoft Cloud Design Patterns](https://learn.microsoft.com/en-us/azure/architecture/reference-architectures/saga/saga) * [Process Manager — Enterprise Integration Patterns](https://www.enterpriseintegrationpatterns.com/patterns/messaging/ProcessManager.html) * [Wolverine Sagas](https://wolverine.netlify.app/guide/durability/sagas.html) # Scope / Context Manager > How Granit encapsulates context changes in IDisposable scopes with automatic restoration ## Definition [Section titled “Definition”](#definition) The Scope Manager pattern encapsulates a context change in an `IDisposable` object. The context is modified at scope creation and automatically restored on `Dispose()`, guaranteeing a return to the previous state even when an exception occurs. ## Diagram [Section titled “Diagram”](#diagram) ``` sequenceDiagram participant A as Application participant CT as CurrentTenant participant AL as AsyncLocal A->>CT: Change(tenantB) CT->>AL: Save previous state (tenantA) CT->>AL: Write tenantB CT-->>A: IDisposable (TenantScope) A->>A: Execute in tenantB context A->>CT: Dispose() CT->>AL: Restore tenantA ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) | Scope | File | Managed context | | ------------------- | ------------------------------------------------------- | ------------------------------------------------------------ | | `TenantScope` | `src/Granit.MultiTenancy/CurrentTenant.cs` | `ICurrentTenant.Change(tenantId)` — restores previous tenant | | `FilterScope` | `src/Granit.Core/DataFiltering/DataFilter.cs` | `IDataFilter.Disable()` — re-enables the filter | | Wolverine behaviors | `src/Granit.Wolverine/Behaviors/UserContextBehavior.cs` | `IWolverineUserContextSetter.Change()` — restores user | All scopes use `AsyncLocal` for thread-safe propagation across `async/await` boundaries. ## Rationale [Section titled “Rationale”](#rationale) The C# `using` pattern guarantees `Dispose()` even when an exception occurs, eliminating the risk of context leaks (a tenant remaining active after an error). ## Usage example [Section titled “Usage example”](#usage-example) ```csharp // Temporary tenant change -- automatic restoration using (currentTenant.Change(adminTenantId)) { // All queries in this scope target adminTenantId List logs = await db.AuditLogs.ToListAsync(ct); } // Previous tenant is automatically restored // Temporarily disable the soft delete filter using (dataFilter.Disable()) { // Deleted records are visible List allPatients = await db.Patients.ToListAsync(ct); } // Filter is automatically re-enabled ``` # Sidecar / Wolverine Behavior > Cross-cutting context restoration (tenant, user, trace) across async message boundaries ## Definition [Section titled “Definition”](#definition) The Sidecar pattern attaches cross-cutting responsibilities to a main component without modifying its code. In the Wolverine context, **Behaviors** (Before/After) execute around each message handler, restoring the context (tenant, user, trace) lost during the transition from HTTP > Outbox > background thread. Granit implements three incoming behaviors and one outgoing middleware, forming a context bridge between HTTP requests and asynchronous processing. ## Diagram [Section titled “Diagram”](#diagram) ``` sequenceDiagram participant HTTP as HTTP Request participant OCM as OutgoingContextMiddleware participant ENV as Envelope (headers) participant TCB as TenantContextBehavior participant UCB as UserContextBehavior participant TrCB as TraceContextBehavior participant H as Handler Note over HTTP,ENV: Outgoing -- header injection HTTP->>OCM: Outgoing message OCM->>ENV: X-Tenant-Id = {tenantId} OCM->>ENV: X-User-Id = {userId} OCM->>ENV: traceparent = {traceId} Note over ENV,H: Incoming -- context restoration ENV->>TCB: Before() TCB->>TCB: ICurrentTenant.Change(tenantId) TCB->>UCB: Before() UCB->>UCB: IWolverineUserContextSetter.Change(userId) UCB->>TrCB: Before() TrCB->>TrCB: Activity.SetParentId(traceparent) TrCB->>H: HandleAsync() H-->>TrCB: Result TrCB-->>UCB: After() -- restore UCB-->>TCB: After() -- restore ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) ### Outgoing middleware [Section titled “Outgoing middleware”](#outgoing-middleware) | Component | File | Role | | --------------------------- | -------------------------------------------------------------- | ----------------------------------------------------------------------------------- | | `OutgoingContextMiddleware` | `src/Granit.Wolverine/Middleware/OutgoingContextMiddleware.cs` | Injects `X-Tenant-Id`, `X-User-Id`, `traceparent` into outgoing Wolverine envelopes | Injection conditions: * `X-Tenant-Id`: only if `currentTenant.Id.HasValue` * `X-User-Id`: only if `currentUserService.IsAuthenticated` and `UserId is { Length: > 0 }` * `traceparent`: if an `Activity` is in progress ### Incoming behaviors [Section titled “Incoming behaviors”](#incoming-behaviors) | Behavior | File | Lifecycle | Role | | ----------------------- | --------------------------------------------------------- | ------------ | ------------------------------------------------------------------------ | | `TenantContextBehavior` | `src/Granit.Wolverine/Behaviors/TenantContextBehavior.cs` | Before/After | Reads `X-Tenant-Id` > `ICurrentTenant.Change(tenantId)` | | `UserContextBehavior` | `src/Granit.Wolverine/Behaviors/UserContextBehavior.cs` | Before/After | Reads `X-User-Id` > `IWolverineUserContextSetter.Change(userId)` | | `TraceContextBehavior` | `src/Granit.Wolverine/Behaviors/TraceContextBehavior.cs` | Before/After | Reads `traceparent` > links the handler’s `Activity` to the trace parent | Each `Before()` returns an `IDisposable` scope. The `After()` disposes the scope, restoring the previous context (important for chained handlers). ### Registration [Section titled “Registration”](#registration) In `src/Granit.Wolverine/Extensions/WolverineHostApplicationBuilderExtensions.cs`: ```csharp opts.Policies.AddMiddleware(); // Behaviors are registered via Wolverine's handler chain discovery ``` ### Internal interface [Section titled “Internal interface”](#internal-interface) | Interface | File | Role | | ----------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------ | | `IWolverineUserContextSetter` | `src/Granit.Wolverine/Internal/IWolverineUserContextSetter.cs` | Allows replacing the user context without HttpContext | | `WolverineCurrentUserService` | `src/Granit.Wolverine/Internal/WolverineCurrentUserService.cs` | `AsyncLocal` implementation: override > HttpContext fallback | ## Rationale [Section titled “Rationale”](#rationale) | Problem | Solution | | ------------------------------------------------------------ | --------------------------------------------------------------------- | | Background handlers have no `HttpContext` | Behaviors restore context from envelope headers | | EF Core audit interceptor needs `ModifiedBy` in background | `UserContextBehavior` restores `ICurrentUserService` via `AsyncLocal` | | OpenTelemetry traces are disjointed between HTTP and async | `TraceContextBehavior` links spans via W3C `traceparent` | | Multi-tenant EF Core query filters do not work in background | `TenantContextBehavior` restores `ICurrentTenant` so filters apply | | The handler must remain pure (no infrastructure code) | All context machinery is in the behaviors, invisible to the handler | ## Usage example [Section titled “Usage example”](#usage-example) ```csharp // The handler is completely pure -- no awareness of behaviors public static class ProcessMedicalReportHandler { public static async Task Handle( ProcessMedicalReportCommand command, ICurrentTenant currentTenant, // restored by TenantContextBehavior ICurrentUserService currentUser, // restored by UserContextBehavior AppDbContext db, CancellationToken cancellationToken) { // currentTenant.Id is the same as the originating HTTP request // currentUser.UserId is the same as the user who initiated the operation MedicalReport report = await db.Reports.FindAsync([command.ReportId], ct) ?? throw new EntityNotFoundException(typeof(MedicalReport), command.ReportId); report.MarkAsProcessed(); await db.SaveChangesAsync(ct); // AuditedEntityInterceptor records: // ModifiedBy = currentUser.UserId (not "system") // The OpenTelemetry Activity is linked to the HTTP trace parent } } ``` ## Further reading [Section titled “Further reading”](#further-reading) * [Sidecar pattern — Microsoft Cloud Design Patterns](https://learn.microsoft.com/en-us/azure/architecture/patterns/sidecar) # Singleton > DI singletons and AsyncLocal per-flow state for tenant context and data filters in Granit ## Definition [Section titled “Definition”](#definition) The Singleton pattern ensures a class has only one instance and provides a global access point to it. In Granit, the Singleton takes two forms: DI singletons (managed by the container) and `AsyncLocal` singletons (thread-safe state per async flow). ## Diagram [Section titled “Diagram”](#diagram) ``` classDiagram class NullTenantContext { -Instance : NullTenantContext +IsAvailable : bool = false +Id : Guid? = null } class CurrentTenant { -_current : AsyncLocal of TenantInfo +Id : Guid? +IsAvailable : bool +Change(tenantId) IDisposable } class DataFilter { -_state : AsyncLocal of ImmutableDictionary +IsEnabled of T() bool +Disable of T() IDisposable } ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) | Singleton | File | Type | Scope | | ---------------------------- | --------------------------------------------------- | --------------------------------- | -------------- | | `NullTenantContext.Instance` | `src/Granit.Core/MultiTenancy/NullTenantContext.cs` | `static readonly` | Global | | `CurrentTenant._current` | `src/Granit.MultiTenancy/CurrentTenant.cs` | `AsyncLocal` | Per async flow | | `DataFilter._state` | `src/Granit.Core/DataFiltering/DataFilter.cs` | `AsyncLocal` | Per async flow | | DI services | All modules | `AddSingleton()` | DI container | **Custom variant — AsyncLocal Singleton**: a `static readonly AsyncLocal` field provides singleton state per `async/await` flow, thread-safe without locks. Each `Task` inherits state from its parent, but modifications are isolated per flow thanks to copy-on-write (`ImmutableDictionary`). ## Rationale [Section titled “Rationale”](#rationale) `NullTenantContext` eliminates `null` checks throughout the framework when multi-tenancy is not installed. `AsyncLocal` singletons allow propagating context (tenant, filters) across `async/await` boundaries without relying on `HttpContext`. ## Usage example [Section titled “Usage example”](#usage-example) ```csharp // NullTenantContext -- always available, never null ICurrentTenant tenant = serviceProvider.GetRequiredService(); // If Granit.MultiTenancy is not installed: tenant.IsAvailable == false // No NullReferenceException, no if (tenant != null) check // AsyncLocal -- state isolated per async flow using (currentTenant.Change(newTenantId)) { // This async flow sees newTenantId await Task.Run(async () => { // This child flow inherits newTenantId Guid? id = currentTenant.Id; // == newTenantId }); } // Here, the previous tenant is restored ``` ## Further reading [Section titled “Further reading”](#further-reading) * [Singleton — refactoring.guru](https://refactoring.guru/design-patterns/singleton) # Soft Delete > How Granit intercepts deletions to preserve audit trails for ISO 27001 and GDPR compliance ## Definition [Section titled “Definition”](#definition) The Soft Delete pattern replaces physical deletion with an `IsDeleted` flag update. The record remains in the database but is hidden by query filters. This pattern is **mandatory** in Granit for ISO 27001 compliance (3-year audit trail) and GDPR (deletion traceability). ## Diagram [Section titled “Diagram”](#diagram) ``` sequenceDiagram participant App as Application participant DB as DbContext participant SDI as SoftDeleteInterceptor participant EF as EF Core App->>DB: db.Patients.Remove(patient) DB->>DB: ChangeTracker: EntityState.Deleted DB->>SDI: SavingChangesAsync() SDI->>SDI: Detects ISoftDeletable SDI->>SDI: EntityState.Deleted to Modified SDI->>SDI: IsDeleted = true SDI->>SDI: DeletedAt = clock.Now SDI->>SDI: DeletedBy = currentUser.UserId DB->>EF: UPDATE Patients SET IsDeleted=1, DeletedAt=..., DeletedBy=... Note over EF: No physical DELETE Note over App,EF: Subsequent reads App->>DB: db.Patients.ToListAsync() DB->>EF: SELECT ... WHERE IsDeleted = 0 Note over EF: The deleted patient is invisible ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) | Component | File | Role | | -------------------------- | -------------------------------------------------------------- | ------------------------------------------------------- | | `ISoftDeletable` | `src/Granit.Core/Domain/ISoftDeletable.cs` | Marker interface: `IsDeleted`, `DeletedAt`, `DeletedBy` | | `FullAuditedEntity` | `src/Granit.Core/Domain/FullAuditedEntity.cs` | Implements `ISoftDeletable` | | `SoftDeleteInterceptor` | `src/Granit.Persistence/Interceptors/SoftDeleteInterceptor.cs` | Converts `Deleted` to `Modified`, fills audit fields | | `ApplyGranitConventions()` | `src/Granit.Persistence/Extensions/ModelBuilderExtensions.cs` | Applies query filter `WHERE IsDeleted = false` | | `IDataFilter` | `src/Granit.Core/DataFiltering/IDataFilter.cs` | Allows temporarily disabling the filter | ### Crypto-shredding (BlobStorage) [Section titled “Crypto-shredding (BlobStorage)”](#crypto-shredding-blobstorage) `BlobStorage` is a special case: deletion is **hybrid**: 1. **Physical deletion** of the S3 object (crypto-shredding, GDPR Art. 17) 2. **Soft delete** of the `BlobDescriptor` in DB (ISO 27001 audit trail) ```text src/Granit.BlobStorage/Internal/DefaultBlobStorage.cs: -> storageClient.DeleteObjectAsync() // physical S3 deletion -> descriptor.MarkAsDeleted() // soft delete in DB ``` ### Regulatory justification [Section titled “Regulatory justification”](#regulatory-justification) | Regulatory requirement | Pattern response | | ------------------------------- | ----------------------------------------------------------- | | ISO 27001 — 3-year audit trail | `DeletedAt` + `DeletedBy` preserved in DB | | GDPR Art. 17 — right to erasure | Crypto-shredding: binary data destroyed, metadata preserved | | GDPR Art. 15 — right of access | Full history is available (who, when, what) | | Internal audit — traceability | `ICurrentUserService.UserId` captures the deletion actor | ## Usage example [Section titled “Usage example”](#usage-example) ```csharp // Standard deletion -- intercepted automatically db.Patients.Remove(patient); await db.SaveChangesAsync(ct); // -> UPDATE: IsDeleted=true, DeletedAt=now, DeletedBy=currentUser // Admin read -- temporarily disable the filter using (dataFilter.Disable()) { // Includes deleted patients (audit, compliance) List allPatients = await db.Patients.ToListAsync(ct); } ``` # Specification (Declarative Query DSL) > How Granit implements a whitelist-first query DSL with expression trees for type-safe filtering ## Definition [Section titled “Definition”](#definition) The Specification pattern encapsulates a business rule in a reusable, composable object. Granit implements it as a **declarative query DSL** (whitelist-first) that builds `Expression>` from filtered, sorted, and paginated criteria — all translated to SQL by EF Core. ## Diagram [Section titled “Diagram”](#diagram) ``` flowchart LR subgraph Declaration["Declaration (whitelist)"] QD["QueryDefinition of Patient"] QB["QueryDefinitionBuilder
.Column() .GlobalSearch()
.FilterGroup() .DefaultSort()"] end subgraph Execution["Execution (expression trees)"] QR["QueryRequest
(page, sort, filter, presets)"] FEB["FilterExpressionBuilder
Expression of Func T bool"] QE["QueryEngine
ApplyFilters then Sort then Paginate"] end subgraph Output["Result"] PR["PagedResult of T"] Meta["QueryMetadata
(columns, operators, presets)"] end QD --> QB QB --> QE QR --> QE QE --> FEB QE --> PR QE --> Meta style Declaration fill:#e8f4fd,stroke:#1a73e8 style Execution fill:#fef3e0,stroke:#e8a317 style Output fill:#e8fde8,stroke:#2d8a4e ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) ### Three packages [Section titled “Three packages”](#three-packages) | Package | Role | | ------------------------------------- | ------------------------------------------------------------------------- | | `Granit.Querying` | Interfaces, fluent builder, DTOs (`QueryRequest`, `PagedResult`) | | `Granit.Querying.EntityFrameworkCore` | Execution engine: expression trees, dynamic sorting, pagination | | `Granit.Querying.Endpoints` | REST binding (`filter[field.op]=value`), metadata + saved views endpoints | ### QueryDefinition — the declarative specification [Section titled “QueryDefinition — the declarative specification”](#querydefinition--the-declarative-specification) Each entity declares its query capabilities via a `QueryDefinition` class. **Nothing is exposed by default** — every filterable, sortable, or groupable column must be explicitly whitelisted. ```csharp public sealed class PatientQueryDefinition : QueryDefinition { public override string Name => "Acme.Patients"; protected override void Configure(QueryDefinitionBuilder builder) => builder .Column(p => p.Niss, c => c.Label("NISS").Filterable().Sortable()) .Column(p => p.Email, c => c.Label("Email").Filterable()) .GlobalSearch(p => p.Niss, p => p.Email, p => p.LastName) .FilterGroup("Status", g => g .Preset("Active", p => p.Status == PatientStatus.Active, isDefault: true) .Preset("Inactive", p => p.Status == PatientStatus.Inactive)) .DefaultSort("-LastName") .DefaultPageSize(25); } ``` ### Operators inferred by type [Section titled “Operators inferred by type”](#operators-inferred-by-type) | C# type | Available operators | | ---------------------------- | -------------------------------------- | | `string` | Eq, Contains, StartsWith, EndsWith, In | | `int`, `decimal`, `double` | Eq, Gt, Gte, Lt, Lte, In, Between | | `DateTime`, `DateTimeOffset` | Eq, Gt, Gte, Lt, Lte, Between | | `bool` | Eq | | `enum` | Eq, In | | `Guid` | Eq, In | ### Execution pipeline [Section titled “Execution pipeline”](#execution-pipeline) The `QueryEngine` applies filters in this order: 1. **Filters** (`filter[field.op]=value`) — validated against the whitelist, compiled to `Expression>` via `FilterExpressionBuilder` 2. **Presets** (`presets[group]=name1,name2`) — OR within a group, AND between groups 3. **Quick filters** (`quickFilters=MyItems,Unread`) — independent AND 4. **Global search** (`search=Alice`) — OR across declared properties 5. **Dynamic sort** (`sort=-createdAt,lastName`) — `OrderBy`/`ThenBy` via expressions 6. **Pagination** — offset (`page`, `pageSize`) or keyset (`cursor`) ### Whitelist-first security [Section titled “Whitelist-first security”](#whitelist-first-security) Fields not declared in the `QueryDefinition` are **silently ignored**. No SQL injection is possible: all values pass through typed `Expression` objects, never through string concatenation. ### Reference files [Section titled “Reference files”](#reference-files) | File | Role | | ----------------------------------------------------------------------------------- | -------------------------------------------------- | | `src/Granit.Querying/QueryDefinition.cs` | Abstract specification class | | `src/Granit.Querying/QueryDefinitionBuilder.cs` | Fluent builder (Column, FilterGroup, GlobalSearch) | | `src/Granit.Querying/Filtering/FilterCriteria.cs` | Record (Field, Operator, Value) | | `src/Granit.Querying/Filtering/FilterOperator.cs` | Operator enum | | `src/Granit.Querying/QueryRequest.cs` | Query DTO (page, sort, filter, presets) | | `src/Granit.Querying.EntityFrameworkCore/Internal/FilterExpressionBuilder.cs` | Filter to Expression compilation | | `src/Granit.Querying.EntityFrameworkCore/Internal/QueryableFilterExtensions.cs` | Filter application on IQueryable | | `src/Granit.Querying.EntityFrameworkCore/Internal/QueryableSortExtensions.cs` | Dynamic sorting via expressions | | `src/Granit.Querying.EntityFrameworkCore/Internal/QueryablePaginationExtensions.cs` | Offset + keyset pagination | | `src/Granit.Querying.Endpoints/Binding/QueryRequestBinder.cs` | REST query string parsing | ## Rationale [Section titled “Rationale”](#rationale) | Problem | Specification solution | | ------------------------------------------------------ | -------------------------------------------------------- | | Exposing all fields for filtering (injection, perf) | Whitelist-first: only declared fields are filterable | | Dynamic SQL construction via concatenation | Typed expression trees, translated by EF Core | | Filtering logic duplicated in every endpoint | `QueryDefinition` centralizes declaration per entity | | Frontend does not know which filters are available | `/meta` endpoint returns columns, operators, and presets | | Preset filters duplicated between frontend and backend | Presets declared server-side, exposed via metadata | ## Usage example [Section titled “Usage example”](#usage-example) ```csharp // --- Declaration (once per entity) --- public sealed class InvoiceQueryDefinition : QueryDefinition { public override string Name => "Billing.Invoices"; protected override void Configure(QueryDefinitionBuilder builder) => builder .Column(i => i.Number, c => c.Label("Invoice #").Filterable().Sortable()) .Column(i => i.Amount, c => c.Label("Amount").Filterable().Sortable()) .Column(i => i.IssuedAt, c => c.Label("Issued").Sortable()) .DateFilter(i => i.IssuedAt, DatePeriod.Last30Days) .FilterGroup("Status", g => g .Preset("Draft", i => i.Status == InvoiceStatus.Draft) .Preset("Sent", i => i.Status == InvoiceStatus.Sent, isDefault: true) .Preset("Paid", i => i.Status == InvoiceStatus.Paid)) .GlobalSearch(i => i.Number, i => i.CustomerName) .DefaultSort("-IssuedAt") .SupportsCursorPagination(i => i.IssuedAt); } // --- Usage (endpoint) --- // GET /api/invoices?filter[amount.gte]=1000&presets[status]=Sent,Paid&sort=-issuedAt&page=1 group.MapQueryEndpoints("/api/invoices", sp => sp.GetRequiredService().Invoices.AsNoTracking()); ``` ## Further reading [Section titled “Further reading”](#further-reading) * [Specification pattern — deviq.com (Ardalis)](https://deviq.com/design-patterns/specification-pattern) * [Query Object — Martin Fowler (PoEAA)](https://martinfowler.com/eaaCatalog/queryObject.html) # State Machine > Explicit state transitions for idempotency and blob lifecycle in Granit ## Definition [Section titled “Definition”](#definition) The State Machine pattern models an object whose behavior changes based on its internal state. Transitions between states are explicit and controlled, preventing invalid states. Granit uses two state machines: one for HTTP idempotency, the other for the blob lifecycle. ## Diagram [Section titled “Diagram”](#diagram) ``` stateDiagram-v2 state "Idempotency" as I { [*] --> Absent Absent --> InProgress : Lock acquired (SET NX) InProgress --> Completed : Handler succeeds InProgress --> Absent : Handler fails (5xx) Completed --> [*] : Replay response } state "BlobStatus" as B { [*] --> Pending : InitiateUploadAsync() Pending --> Uploading : MarkAsUploading() Uploading --> Valid : MarkAsValid() Uploading --> Rejected : MarkAsRejected() Valid --> Deleted : MarkAsDeleted() Rejected --> Deleted : MarkAsDeleted() } ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) ### IdempotencyState [Section titled “IdempotencyState”](#idempotencystate) | Component | File | | ----------------------- | ---------------------------------------------------------- | | `IdempotencyState` | `src/Granit.Idempotency/Models/IdempotencyState.cs` | | `IdempotencyMiddleware` | `src/Granit.Idempotency/Internal/IdempotencyMiddleware.cs` | States: `Absent` -> `InProgress` -> `Completed` ### BlobStatus [Section titled “BlobStatus”](#blobstatus) | Component | File | | ---------------- | ------------------------------------------ | | `BlobStatus` | `src/Granit.BlobStorage/BlobStatus.cs` | | `BlobDescriptor` | `src/Granit.BlobStorage/BlobDescriptor.cs` | States: `Pending` -> `Uploading` -> `Valid`/`Rejected` -> `Deleted` Transitions are encapsulated in methods on `BlobDescriptor`: `MarkAsUploading()`, `MarkAsValid()`, `MarkAsRejected()`, `MarkAsDeleted()`. Each method validates the current state before the transition. ## Rationale [Section titled “Rationale”](#rationale) State machines make transitions explicit and testable. A blob cannot go from `Pending` to `Deleted` directly — it must traverse intermediate states. Idempotency uses states to manage concurrency (InProgress -> 409 Conflict). ## Usage example [Section titled “Usage example”](#usage-example) ```csharp // Transitions are secured by typed methods BlobDescriptor descriptor = new() { Status = BlobStatus.Pending }; descriptor.MarkAsUploading(); // Pending -> Uploading descriptor.MarkAsValid(); // Uploading -> Valid descriptor.MarkAsDeleted(clock.Now, "GDPR Art. 17"); // Valid -> Deleted // Invalid transition -> exception descriptor.MarkAsValid(); // Deleted -> Valid: InvalidOperationException ``` ## Further reading [Section titled “Further reading”](#further-reading) * [State — refactoring.guru](https://refactoring.guru/design-patterns/state) # Strategy > Interchangeable algorithms for tenant isolation, blob keys, and encryption in Granit ## Definition [Section titled “Definition”](#definition) The Strategy pattern defines a family of algorithms, encapsulates each one in a separate class, and makes them interchangeable. Algorithm selection is delegated to configuration or runtime. ## Diagram [Section titled “Diagram”](#diagram) ``` classDiagram class TenantIsolationStrategy { SharedDatabase SchemaPerTenant DatabasePerTenant } class ITenantIsolationStrategyProvider { +CreateDbContext() } class SharedDatabaseDbContextFactory class TenantPerSchemaDbContextFactory class TenantPerDatabaseDbContextFactory ITenantIsolationStrategyProvider <|.. SharedDatabaseDbContextFactory ITenantIsolationStrategyProvider <|.. TenantPerSchemaDbContextFactory ITenantIsolationStrategyProvider <|.. TenantPerDatabaseDbContextFactory TenantIsolationStrategy ..> ITenantIsolationStrategyProvider : selects class IBlobKeyStrategy { +BuildObjectKey() +ResolveBucketName() } class PrefixBlobKeyStrategy IBlobKeyStrategy <|.. PrefixBlobKeyStrategy class IStringEncryptionProvider { +EncryptAsync() +DecryptAsync() } class AesStringEncryptionProvider IStringEncryptionProvider <|.. AesStringEncryptionProvider ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) | Strategy | Interface | File | Implementations | | ----------------- | ---------------------------------- | -------------------------------------------- | -------------------------------------------------------- | | Tenant isolation | `ITenantIsolationStrategyProvider` | `src/Granit.Persistence/MultiTenancy/` | `SharedDatabase`, `TenantPerSchema`, `TenantPerDatabase` | | S3 key | `IBlobKeyStrategy` | `src/Granit.BlobStorage/IBlobKeyStrategy.cs` | `PrefixBlobKeyStrategy` | | Encryption | `IStringEncryptionProvider` | `src/Granit.Encryption/Providers/` | `AesStringEncryptionProvider` | | Tenant resolution | `ITenantResolver` | `src/Granit.MultiTenancy/Resolvers/` | `HeaderTenantResolver`, `JwtClaimTenantResolver` | **Custom variant — Enum-based Selection**: `TenantIsolationStrategy` is an enum used as a selection key. The factory picks the implementation via a switch expression — no reflection or complex configuration. ## Rationale [Section titled “Rationale”](#rationale) The choice between SharedDatabase, SchemaPerTenant, and DatabasePerTenant has major implications on cost, performance, and security. The Strategy pattern allows changing this decision without modifying application code. ## Usage example [Section titled “Usage example”](#usage-example) ```csharp // The strategy is selected via appsettings.json (section "Persistence") // { "Persistence": { "IsolationStrategy": "SchemaPerTenant" } } services.AddGranitPersistence(); // Application code is identical regardless of the strategy public sealed class PatientService(AppDbContext db) { public async Task FindAsync(Guid id, CancellationToken cancellationToken) => await db.Patients.FindAsync([id], ct); // SharedDatabase -> WHERE Id = @id AND TenantId = @tid // SchemaPerTenant -> SET search_path TO tenant_xxx; SELECT ... WHERE Id = @id // DatabasePerTenant -> Connection to tenant_xxx_db; SELECT ... WHERE Id = @id } ``` ## Further reading [Section titled “Further reading”](#further-reading) * [Strategy — refactoring.guru](https://refactoring.guru/design-patterns/strategy) # Template Method > Module lifecycle hooks and validator base classes in Granit ## Definition [Section titled “Definition”](#definition) The Template Method pattern defines the skeleton of an algorithm in a base class, letting subclasses redefine certain steps without changing the overall structure. The base class calls methods in a predefined order; subclasses override the ones relevant to them. ## Diagram [Section titled “Diagram”](#diagram) ``` classDiagram class GranitModule { +ConfigureServices(context)* +ConfigureServicesAsync(context) +OnApplicationInitialization(context)* +OnApplicationInitializationAsync(context) } class GranitWolverineModule { +ConfigureServices(context) } class GranitFeaturesModule { +ConfigureServices(context) } class GranitValidator { #CascadeMode = Continue } class AbstractValidator { FluentValidation } GranitModule <|-- GranitWolverineModule GranitModule <|-- GranitFeaturesModule AbstractValidator <|-- GranitValidator ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) | Base class | File | Hooks | | -------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | `GranitModule` | `src/Granit.Core/Modularity/GranitModule.cs` | `ConfigureServices()`, `ConfigureServicesAsync()`, `OnApplicationInitialization()`, `OnApplicationInitializationAsync()` | | `GranitValidator` | `src/Granit.Validation/GranitValidator.cs` | Inherits from `AbstractValidator` with `CascadeMode.Continue` by default | **Custom variant — Dual Sync/Async**: `ConfigureServicesAsync()` delegates to `ConfigureServices()` by default. A module can override only the sync version or only the async version — no obligation to implement both. ## Rationale [Section titled “Rationale”](#rationale) The module lifecycle (discovery -> configuration -> initialization) is fixed. Only the content of each step varies between modules. The Template Method guarantees that the order is always respected. ## Usage example [Section titled “Usage example”](#usage-example) ```csharp [DependsOn(typeof(GranitPersistenceModule))] public sealed class MyAppHostModule : GranitModule { // Override only the necessary steps public override void ConfigureServices(ServiceConfigurationContext context) { context.Services.AddScoped(); } public override void OnApplicationInitialization(ApplicationInitializationContext context) { WebApplication app = context.GetApplicationBuilder(); app.MapControllers(); } // ConfigureServicesAsync() and OnApplicationInitializationAsync() // are not overridden -- they delegate to the sync versions above } ``` ## Further reading [Section titled “Further reading”](#further-reading) * [Template Method — refactoring.guru](https://refactoring.guru/design-patterns/template-method) # Transactional Outbox > Reliable event publishing through same-transaction persistence via Wolverine Outbox ## Definition [Section titled “Definition”](#definition) The Transactional Outbox pattern guarantees reliable event publishing by persisting events in the same transaction as business data. If the transaction commits, events are guaranteed to be delivered. If it rolls back, events are discarded along with the data — no inconsistency possible. Granit delegates the Outbox to Wolverine, which stores messages in a dedicated table within the application database. A background dispatcher relays messages to configured transports after commit. ## Diagram [Section titled “Diagram”](#diagram) ``` sequenceDiagram participant H as Handler participant DB as DbContext participant OT as Outbox Table participant TX as Transaction participant D as Dispatcher participant T as Transport participant C as Consumer H->>DB: UPDATE patients SET ... H->>OT: INSERT INTO outbox (SendWebhookCommand) H->>TX: COMMIT Note over DB,OT: Atomic -- same transaction D->>OT: SELECT non-dispatched D->>T: Publish (PostgreSQL queue / RabbitMQ) D->>OT: DELETE dispatched T->>C: Delivery with retry Note over H,C: If ROLLBACK -- no message in the Outbox ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) ### Wolverine configuration [Section titled “Wolverine configuration”](#wolverine-configuration) The Outbox is activated by provider modules (PostgreSQL, SQL Server), not by `Granit.Wolverine` itself (which remains transport-agnostic). | Component | File | Role | | --------------------------------- | ------------------------------------------------------------------------------ | ----------------------------------------------------------- | | `AddGranitWolverine()` | `src/Granit.Wolverine/Extensions/WolverineHostApplicationBuilderExtensions.cs` | Configures the bus, retries, behaviors — **not the Outbox** | | `GranitWolverinePostgresqlModule` | `src/Granit.Wolverine.Postgresql/GranitWolverinePostgresqlModule.cs` | Activates the PostgreSQL Outbox | ### Event routing [Section titled “Event routing”](#event-routing) ```csharp // IDomainEvent -> local queue (NEVER the Outbox) opts.PublishMessage().ToLocalQueue("domain-events"); // IIntegrationEvent -> configured transport (with Outbox) // Routing is configured by the provider module ``` ### Fan-out pattern (native Wolverine) [Section titled “Fan-out pattern (native Wolverine)”](#fan-out-pattern-native-wolverine) Handlers returning `IEnumerable` produce multiple Outbox messages within the same transaction: | Handler | File | Messages produced | | ---------------------- | ------------------------------------------------------ | ------------------------------------------------------ | | `WebhookFanoutHandler` | `src/Granit.Webhooks/Handlers/WebhookFanoutHandler.cs` | N x `SendWebhookCommand` (one per active subscription) | ### Atomic rescheduling (BackgroundJobs) [Section titled “Atomic rescheduling (BackgroundJobs)”](#atomic-rescheduling-backgroundjobs) | Component | File | Role | | ---------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------- | | `RecurringJobSchedulingMiddleware` | `src/Granit.BackgroundJobs/Internal/RecurringJobSchedulingMiddleware.cs` | Inserts the next scheduled message into the Outbox **before** handler execution | The `Before()` middleware writes the next scheduled message and updates `NextExecutionAt` in DB. Everything is in the same Outbox transaction. If the handler fails, the rollback also cancels the rescheduling — no duplicates or lost messages. ## Rationale [Section titled “Rationale”](#rationale) | Problem | Solution | | -------------------------------------------------------------------- | ----------------------------------------------------------------------- | | ”Fire and forget” after commit loses messages if the process crashes | The Outbox persists the message BEFORE commit, in the same transaction | | Dual write (DB + message broker) creates inconsistencies | Single transaction eliminates the problem | | Domain events must not cross service boundaries | `IDomainEvent` is explicitly routed locally, never to the Outbox | | Background jobs must reschedule atomically | `RecurringJobSchedulingMiddleware` uses the Outbox for atomicity | | Webhook fan-out: N notifications for 1 event | `IEnumerable` produces N Outbox messages atomically | ## Usage example [Section titled “Usage example”](#usage-example) ```csharp // The handler returns messages -- Wolverine persists them in the Outbox public static class InvoiceCreatedHandler { public static IEnumerable Handle( CreateInvoiceCommand command, InvoiceDbContext db) { Invoice invoice = new() { PatientId = command.PatientId, Amount = command.Amount }; db.Invoices.Add(invoice); // SaveChangesAsync is called by Wolverine (auto-transaction) // These messages are persisted in the Outbox, not sent immediately yield return new SendInvoiceEmailCommand { InvoiceId = invoice.Id }; yield return new NotifyAccountingEvent { InvoiceId = invoice.Id, Amount = invoice.Amount }; // If the transaction fails -- no message is sent // If the transaction succeeds -- both messages are guaranteed delivered } } ``` ## Further reading [Section titled “Further reading”](#further-reading) * [Transactional Outbox — microservices.io (Chris Richardson)](https://microservices.io/patterns/data/transactional-outbox.html) # Unit of Work > How Granit uses EF Core DbContext as an implicit Unit of Work with a chained interceptor pipeline ## Definition [Section titled “Definition”](#definition) The Unit of Work maintains a list of objects modified during a business transaction and coordinates atomic persistence of those changes. In Granit, `DbContext` is the Unit of Work: the `ChangeTracker` accumulates mutations, and `SaveChangesAsync()` persists them in a single transaction. ## Diagram [Section titled “Diagram”](#diagram) ``` sequenceDiagram participant H as Handler / Endpoint participant DB as DbContext (Unit of Work) participant AI as AuditedEntityInterceptor participant VI as VersioningInterceptor participant DEI as DomainEventDispatcherInterceptor participant SDI as SoftDeleteInterceptor participant PG as PostgreSQL H->>DB: entity.Mutate() H->>DB: db.Add(newEntity) H->>DB: db.Remove(oldEntity) H->>DB: SaveChangesAsync() activate DB DB->>AI: SavingChanges -- CreatedAt/By, ModifiedAt/By, TenantId AI->>VI: SavingChanges -- BusinessId, Version VI->>DEI: SavingChanges -- collects IDomainEvent DEI->>SDI: SavingChanges -- DELETE to UPDATE (IsDeleted) SDI->>PG: BEGIN + INSERT/UPDATE PG-->>DB: COMMIT DB->>DEI: SavedChanges -- dispatch events deactivate DB DB-->>H: rows affected ``` ## Implementation in Granit [Section titled “Implementation in Granit”](#implementation-in-granit) Granit does not expose an explicit `IUnitOfWork` interface. The EF Core `DbContext` fulfills this role, augmented by a **chain of interceptors** that execute in a strictly defined order on each `SaveChangesAsync()`. ### Interceptor chain [Section titled “Interceptor chain”](#interceptor-chain) Registered in `src/Granit.Persistence/Extensions/DbContextOptionsBuilderExtensions.cs`: | Order | Interceptor | Role | | ----- | ---------------------------------- | ------------------------------------------------------------------ | | 1 | `AuditedEntityInterceptor` | ISO 27001 — `CreatedAt/By`, `ModifiedAt/By`, `TenantId`, auto-`Id` | | 2 | `VersioningInterceptor` | `BusinessId` and `Version` on `IVersioned` entities | | 3 | `DomainEventDispatcherInterceptor` | Collects `IDomainEvent` before save, dispatches after commit | | 4 | `SoftDeleteInterceptor` | GDPR — converts `DELETE` to `UPDATE` (`IsDeleted`, `DeletedAt/By`) | **The order is critical**: `SoftDeleteInterceptor` is last because it changes `EntityState.Deleted` to `Modified`, which would hide the original state from preceding interceptors. ### DbContextFactory (Unit of Work boundary) [Section titled “DbContextFactory (Unit of Work boundary)”](#dbcontextfactory-unit-of-work-boundary) Each call to `CreateDbContextAsync()` returns a fresh `DbContext` instance — a new Unit of Work. Interceptors are injected automatically by the factory. ```csharp // Typical pattern: one UoW per operation await using TContext db = await dbContextFactory .CreateDbContextAsync(cancellationToken).ConfigureAwait(false); db.DeliveryAttempts.Add(attempt); await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); ``` ### Wolverine Outbox integration [Section titled “Wolverine Outbox integration”](#wolverine-outbox-integration) `AutoApplyTransactions()` wraps each Wolverine handler in a DB transaction. Outbox messages and domain changes are committed atomically in the same `SaveChangesAsync()`. ### Roslyn Analyzer GR-EF001 [Section titled “Roslyn Analyzer GR-EF001”](#roslyn-analyzer-gr-ef001) `src/Granit.Analyzers/SynchronousSaveChangesAnalyzer.cs` emits a warning when synchronous `SaveChanges()` is called instead of `SaveChangesAsync()`. ### Reference files [Section titled “Reference files”](#reference-files) | File | Role | | --------------------------------------------------------------------------------------------------- | ---------------------------- | | `src/Granit.Persistence/Interceptors/AuditedEntityInterceptor.cs` | ISO 27001 audit trail | | `src/Granit.Persistence/Interceptors/SoftDeleteInterceptor.cs` | GDPR soft delete | | `src/Granit.Persistence/Interceptors/VersioningInterceptor.cs` | Auto-versioning | | `src/Granit.Persistence/Interceptors/DomainEventDispatcherInterceptor.cs` | Atomic domain events | | `src/Granit.Persistence/Extensions/DbContextOptionsBuilderExtensions.cs` | Interceptor chain wiring | | `src/Granit.Persistence/Extensions/PersistenceServiceCollectionExtensions.cs` | DI registration (all Scoped) | | `src/Granit.Wolverine.Postgresql/Extensions/WolverinePostgresqlHostApplicationBuilderExtensions.cs` | Transactional outbox | | `src/Granit.Analyzers/SynchronousSaveChangesAnalyzer.cs` | Analyzer GR-EF001 | ## Rationale [Section titled “Rationale”](#rationale) | Problem | Unit of Work solution | | -------------------------------------------------- | -------------------------------------------------------------------- | | Partial writes on error | `SaveChangesAsync()` = atomic transaction | | Audit trail scattered across each handler | Interceptors apply audit cross-cuttingly | | Accidental hard-delete (GDPR) | `SoftDeleteInterceptor` intercepts before `DELETE` | | Domain events dispatched before commit | `DomainEventDispatcherInterceptor` collects before, dispatches after | | Synchronous `SaveChanges()` blocks the thread pool | Analyzer GR-EF001 detects and warns at compile-time | ## Usage example [Section titled “Usage example”](#usage-example) ```csharp // The handler is unaware of interceptors -- they execute // automatically on each SaveChangesAsync() public static async Task Handle( ArchivePatientCommand command, PatientDbContext db, CancellationToken cancellationToken) { Patient patient = await db.Patients.FindAsync([command.PatientId], cancellationToken) ?? throw new EntityNotFoundException(typeof(Patient), command.PatientId); patient.Archive(); // ModifiedAt/By filled by AuditedEntityInterceptor db.Remove(patient); // SoftDeleteInterceptor -> IsDeleted = true // DomainEventDispatcherInterceptor collects PatientArchivedEvent await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); // 1 transaction: UPDATE (audit) + UPDATE (soft delete) + Outbox message // Then dispatch PatientArchivedEvent } ``` ## Further reading [Section titled “Further reading”](#further-reading) * [Unit of Work — Martin Fowler (PoEAA)](https://martinfowler.com/eaaCatalog/unitOfWork.html) # From Zero to CRUD in 10 Minutes with Granit > Build a complete CRUD module with Granit in under 10 minutes — from entity definition to validated endpoints with full audit trail and soft delete. You have heard about the module system, browsed the pattern catalog, maybe skimmed an ADR or two. Now you want to build something. This tutorial walks you through creating a **Product catalog module** from an empty folder to a working API with full audit trail, soft delete, validation, and tenant isolation — all in under 10 minutes. The entire module is roughly 150 lines of code. Granit handles the rest. ## Step 1: Create the entity [Section titled “Step 1: Create the entity”](#step-1-create-the-entity) Every Granit module starts with a **domain entity**. The framework provides an entity hierarchy that layers audit and lifecycle behavior on top of a base `Entity` class: * **`Entity`** — `Guid Id` only. * **`CreationAuditedEntity`** — adds `CreatedAt` and `CreatedBy`. * **`AuditedEntity`** — adds `ModifiedAt` and `ModifiedBy`. * **`FullAuditedEntity`** — adds `ISoftDeletable` (`IsDeleted`, `DeletedAt`, `DeletedBy`). For a product catalog, you want the full trail. A product can be created, updated, and soft-deleted — never hard-deleted. Domain/Product.cs ```csharp using Granit.Core.Domain; namespace ProductCatalog.Domain; public sealed class Product : FullAuditedEntity { public string Name { get; set; } = string.Empty; public string? Description { get; set; } public decimal Price { get; set; } public string Sku { get; set; } = string.Empty; } ``` That is it. No marker interfaces for audit, no manual `DateTime.UtcNow` calls. The `AuditedEntityInterceptor` and `SoftDeleteInterceptor` from `Granit.Persistence` populate the audit fields automatically when EF Core saves changes. ## Step 2: Create the DbContext [Section titled “Step 2: Create the DbContext”](#step-2-create-the-dbcontext) Granit follows the **isolated DbContext** pattern: each module owns its own `DbContext`, decoupled from the host application’s context. This keeps module boundaries clean and avoids a single monolithic context with hundreds of entity sets. EntityFrameworkCore/ProductDbContext.cs ```csharp using Granit.Core.DataFiltering; using Granit.Core.MultiTenancy; using Granit.Persistence.Extensions; using Microsoft.EntityFrameworkCore; using ProductCatalog.Domain; namespace ProductCatalog.EntityFrameworkCore; internal sealed class ProductDbContext( DbContextOptions options, ICurrentTenant? currentTenant = null, IDataFilter? dataFilter = null) : DbContext(options) { public DbSet Products => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); modelBuilder.ApplyConfigurationsFromAssembly(typeof(ProductDbContext).Assembly); modelBuilder.ApplyGranitConventions(currentTenant, dataFilter); } } ``` Three things to note: * **`ICurrentTenant?` and `IDataFilter?`** are injected as optional parameters. If multi-tenancy is not installed, a `NullTenantContext` is used and the tenant filter is simply skipped. * **`ApplyGranitConventions`** registers query filters for `ISoftDeletable`, `IMultiTenant`, `IActive`, `IProcessingRestrictable`, and `IPublishable`. You never write a manual `HasQueryFilter` — Granit combines all applicable filters into a single expression per entity type. * **`ApplyConfigurationsFromAssembly`** picks up any `IEntityTypeConfiguration` in the same assembly. Add one if you need column constraints, indexes, or value conversions. To register the context with Granit’s interceptor wiring, use `AddGranitDbContext`: ProductModule.cs (partial — context registration) ```csharp services.AddGranitDbContext(options => options.UseNpgsql(configuration.GetConnectionString("ProductCatalog"))); ``` This replaces the boilerplate of manually resolving `AuditedEntityInterceptor` and `SoftDeleteInterceptor` from the service provider. One line, all interceptors wired. ## Step 3: Create Request and Response records [Section titled “Step 3: Create Request and Response records”](#step-3-create-request-and-response-records) Granit follows strict DTO conventions: **`Request`** for input bodies, **`Response`** for return types. Never suffix with `Dto`. Never return EF Core entities directly — always map to a response record. Prefix your DTOs with the module context to avoid OpenAPI schema collisions. `ProductCreateRequest`, not `CreateRequest`. Contracts/ProductCreateRequest.cs ```csharp namespace ProductCatalog.Contracts; public sealed record ProductCreateRequest( string Name, string? Description, decimal Price, string Sku); ``` Contracts/ProductUpdateRequest.cs ```csharp namespace ProductCatalog.Contracts; public sealed record ProductUpdateRequest( string Name, string? Description, decimal Price, string Sku); ``` Contracts/ProductResponse.cs ```csharp namespace ProductCatalog.Contracts; public sealed record ProductResponse( Guid Id, string Name, string? Description, decimal Price, string Sku, DateTimeOffset CreatedAt, string CreatedBy, DateTimeOffset? ModifiedAt, string? ModifiedBy); ``` The response includes audit fields. The caller sees who created or modified each product without any extra work on your side. ## Step 4: Add FluentValidation [Section titled “Step 4: Add FluentValidation”](#step-4-add-fluentvalidation) Granit uses **FluentValidation** with structured error codes. Validators are discovered automatically when registered with `AddGranitValidatorsFromAssemblyContaining`. Error messages are returned as codes (e.g., `Granit:Validation:NotEmptyValidator`) that the frontend resolves from its localization dictionary. Validators/ProductCreateRequestValidator.cs ```csharp using FluentValidation; using ProductCatalog.Contracts; namespace ProductCatalog.Validators; internal sealed class ProductCreateRequestValidator : AbstractValidator { public ProductCreateRequestValidator() { RuleFor(x => x.Name) .NotEmpty() .MaximumLength(200); RuleFor(x => x.Sku) .NotEmpty() .MaximumLength(50); RuleFor(x => x.Price) .GreaterThanOrEqualTo(0); } } ``` Write a matching validator for `ProductUpdateRequest`. The rules are usually identical — if they diverge later (e.g., SKU becomes immutable on update), having separate validators pays off. ## Step 5: Create the endpoints [Section titled “Step 5: Create the endpoints”](#step-5-create-the-endpoints) Granit uses **Minimal APIs** with route groups. Validation is applied per-endpoint using the `.ValidateBody()` extension method, which adds a `FluentValidationEndpointFilter`. If the request body fails validation, the filter short-circuits with a **422 Unprocessable Entity** response containing `HttpValidationProblemDetails`. For errors, always use `TypedResults.Problem` (RFC 7807 Problem Details). Never return raw strings or `BadRequest`. Endpoints/ProductEndpoints.cs ```csharp using Granit.Validation.AspNetCore; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Routing; using Microsoft.EntityFrameworkCore; using ProductCatalog.Contracts; using ProductCatalog.EntityFrameworkCore; namespace ProductCatalog.Endpoints; internal static class ProductEndpoints { public static void MapProductEndpoints(this IEndpointRouteBuilder routes) { RouteGroupBuilder group = routes .MapGroup("/api/v1/products") .WithTags("Products"); group.MapGet("/", ListProducts); group.MapGet("/{id:guid}", GetProduct); group.MapPost("/", CreateProduct).ValidateBody(); group.MapPut("/{id:guid}", UpdateProduct).ValidateBody(); group.MapDelete("/{id:guid}", DeleteProduct); } private static async Task>> ListProducts( ProductDbContext db, CancellationToken cancellationToken) { List products = await db.Products .Select(p => new ProductResponse( p.Id, p.Name, p.Description, p.Price, p.Sku, p.CreatedAt, p.CreatedBy, p.ModifiedAt, p.ModifiedBy)) .ToListAsync(cancellationToken); return TypedResults.Ok(products); } private static async Task, ProblemHttpResult>> GetProduct( Guid id, ProductDbContext db, CancellationToken cancellationToken) { ProductResponse? product = await db.Products .Where(p => p.Id == id) .Select(p => new ProductResponse( p.Id, p.Name, p.Description, p.Price, p.Sku, p.CreatedAt, p.CreatedBy, p.ModifiedAt, p.ModifiedBy)) .FirstOrDefaultAsync(cancellationToken); if (product is null) { return TypedResults.Problem( detail: $"Product {id} not found.", statusCode: StatusCodes.Status404NotFound); } return TypedResults.Ok(product); } private static async Task> CreateProduct( ProductCreateRequest request, ProductDbContext db, CancellationToken cancellationToken) { Product product = new() { Name = request.Name, Description = request.Description, Price = request.Price, Sku = request.Sku, }; db.Products.Add(product); await db.SaveChangesAsync(cancellationToken); ProductResponse response = new( product.Id, product.Name, product.Description, product.Price, product.Sku, product.CreatedAt, product.CreatedBy, product.ModifiedAt, product.ModifiedBy); return TypedResults.Created($"/api/v1/products/{product.Id}", response); } private static async Task, ProblemHttpResult>> UpdateProduct( Guid id, ProductUpdateRequest request, ProductDbContext db, CancellationToken cancellationToken) { Product? product = await db.Products .FirstOrDefaultAsync(p => p.Id == id, cancellationToken); if (product is null) { return TypedResults.Problem( detail: $"Product {id} not found.", statusCode: StatusCodes.Status404NotFound); } product.Name = request.Name; product.Description = request.Description; product.Price = request.Price; product.Sku = request.Sku; await db.SaveChangesAsync(cancellationToken); ProductResponse response = new( product.Id, product.Name, product.Description, product.Price, product.Sku, product.CreatedAt, product.CreatedBy, product.ModifiedAt, product.ModifiedBy); return TypedResults.Ok(response); } private static async Task> DeleteProduct( Guid id, ProductDbContext db, CancellationToken cancellationToken) { Product? product = await db.Products .FirstOrDefaultAsync(p => p.Id == id, cancellationToken); if (product is null) { return TypedResults.Problem( detail: $"Product {id} not found.", statusCode: StatusCodes.Status404NotFound); } db.Products.Remove(product); await db.SaveChangesAsync(cancellationToken); return TypedResults.NoContent(); } } ``` Notice that the DELETE handler calls `Remove()`, not a manual `IsDeleted = true`. The `SoftDeleteInterceptor` from `Granit.Persistence` intercepts the delete operation and converts it to a soft delete automatically. The entity stays in the database with `IsDeleted = true`, `DeletedAt` timestamped, and `DeletedBy` set to the current user. The global query filter ensures soft-deleted products are excluded from all subsequent queries. ## Step 6: Wire up the module [Section titled “Step 6: Wire up the module”](#step-6-wire-up-the-module) Now bring everything together in the **module class** and the **application entry point**. ProductModule.cs ```csharp using Granit.Core.Modularity; using Granit.Persistence; using Granit.Validation.Extensions; using Microsoft.Extensions.Configuration; using ProductCatalog.EntityFrameworkCore; using ProductCatalog.Validators; namespace ProductCatalog; [DependsOn(typeof(GranitPersistenceModule))] public sealed class ProductModule : GranitModule { public override void ConfigureServices(ServiceConfigurationContext context) { IConfiguration configuration = context.Configuration; context.Services.AddGranitDbContext(options => options.UseNpgsql(configuration.GetConnectionString("ProductCatalog"))); context.Services.AddGranitValidatorsFromAssemblyContaining(); } } ``` The `[DependsOn(typeof(GranitPersistenceModule))]` attribute declares a direct dependency. Granit resolves the module graph topologically — `GranitPersistenceModule` and its own dependencies (`GranitTimingModule`, `GranitGuidsModule`, `GranitSecurityModule`, `GranitExceptionHandlingModule`) are configured first. You only declare **direct** dependencies; transitive ones are resolved automatically. The validator registration call `AddGranitValidatorsFromAssemblyContaining` scans the assembly for all `IValidator` implementations and registers them as scoped services. Without this call, `FluentValidationEndpointFilter` silently passes through without validation — a subtle bug that is easy to miss. Now wire the module into your application: Program.cs ```csharp using Granit.Core.Extensions; using ProductCatalog; using ProductCatalog.Endpoints; var builder = WebApplication.CreateBuilder(args); builder.AddGranit(granit => { granit.AddModule(); }); var app = builder.Build(); app.UseGranit(); app.MapProductEndpoints(); app.Run(); ``` `AddGranit` discovers the full module dependency tree from `ProductModule`. `UseGranit` runs the post-build initialization phase (e.g., running migrations, seeding data if modules opt into it). Endpoint mapping is explicit — you decide which routes are exposed. ## Step 7: Run and test with Scalar [Section titled “Step 7: Run and test with Scalar”](#step-7-run-and-test-with-scalar) Add the `Granit.ApiDocumentation` package to get **Scalar** (the OpenAPI documentation UI) out of the box. Start the application and navigate to `/scalar` to see your endpoints, try requests, and inspect the generated OpenAPI schema. Create a product: ```http POST /api/v1/products Content-Type: application/json { "name": "Mechanical Keyboard", "description": "Cherry MX Brown switches, hot-swappable", "price": 149.99, "sku": "KB-MX-001" } ``` The response includes the auto-generated `Id` and audit fields: ```json { "id": "01968a3c-...", "name": "Mechanical Keyboard", "description": "Cherry MX Brown switches, hot-swappable", "price": 149.99, "sku": "KB-MX-001", "createdAt": "2026-03-07T14:30:00Z", "createdBy": "user@example.com", "modifiedAt": null, "modifiedBy": null } ``` Try sending an invalid request (empty name, negative price) and watch FluentValidation return a 422 with structured error codes. Delete the product, then query the list — it disappears from results. But if you check the database directly, the row is still there with `IsDeleted = true`. That is soft delete working through the interceptor and query filter. ## What you got for free [Section titled “What you got for free”](#what-you-got-for-free) Stop and count what Granit handled without a single line of infrastructure code from you: * **Audit trail** — `CreatedAt`, `CreatedBy`, `ModifiedAt`, `ModifiedBy` populated automatically by `AuditedEntityInterceptor`. ISO 27001 compliance out of the box. * **Soft delete** — `Remove()` calls are intercepted and converted to logical deletes. `IsDeleted`, `DeletedAt`, `DeletedBy` are set. GDPR right-to-erasure support without manual plumbing. * **Global query filters** — soft-deleted entities are excluded from all queries. If multi-tenancy is installed, tenant isolation is enforced at the query level. Filters can be bypassed at runtime with `IDataFilter.Disable()` when you need to include deleted records (e.g., admin audit views). * **Validation pipeline** — FluentValidation runs before your handler. Structured error codes are returned as RFC 7807 Problem Details. The frontend maps codes to localized messages. * **Sequential GUIDs** — `IGuidGenerator` produces sequential GUIDs optimized for clustered indexes. No fragmentation, no performance cliff on large tables. * **Interceptor wiring** — `AddGranitDbContext` resolves and attaches EF Core interceptors from the DI container. You never manually configure them. ## Further reading [Section titled “Further reading”](#further-reading) * [Getting started](/getting-started/) — installation, prerequisites, first project setup. * [Persistence module reference](/reference/modules/persistence/) — interceptors, migrations, query filters, data filtering. * [Core module reference](/reference/modules/core/) — module system, entity hierarchy, IGuidGenerator, IClock. * [Isolated DbContext pattern](/architecture/patterns/layered-architecture/) — why each module owns its context. * [FluentValidation (ADR-006)](/architecture/adr/006-fluentvalidation/) — decision record for the validation stack. # GDPR by Design: Privacy Patterns in a .NET Framework > GDPR compliance is not a checkbox. It is an architectural constraint enforced at the framework level. Here is how Granit implements data minimization, right to erasure, and pseudonymization by default. A developer adds a `DELETE FROM Patients WHERE Id = @id` query in a service class. The feature works. The code review passes. Six months later, during an ISO 27001 audit, someone asks: “Can you prove this patient’s data was deleted? Who deleted it? When? Was the deletion propagated to blob storage and notification logs?” The answer is no, because a physical delete leaves no trace. This is the core problem with treating privacy as an application-level concern. Individual developers make individual decisions about deletion, encryption, and data retention. Some get it right. Most do not, because the framework does not force them to. Granit takes a different approach. **Privacy is a framework constraint, not an application feature.** The patterns described here are not optional plugins. They are interceptors, query filters, and abstractions that activate by default when you implement the right interfaces. ## Data minimization: collect only what you need, expose only what you must [Section titled “Data minimization: collect only what you need, expose only what you must”](#data-minimization-collect-only-what-you-need-expose-only-what-you-must) GDPR Article 5(1)(c) requires **data minimization** — personal data must be adequate, relevant, and limited to what is necessary. At the framework level, Granit enforces this in two places. **Identity cache, not identity copy.** `Granit.Identity.EntityFrameworkCore` maintains a user cache that syncs at login time using the cache-aside pattern. The application never stores a full copy of the identity provider’s user record. When a user logs in, the cache updates with only the fields the application needs. When a user is deleted from the identity provider, the next sync attempt finds nothing and the cache entry can be cleaned up. LoginSyncHandler.cs ```csharp public class LoginSyncHandler( IIdentityProvider identityProvider, IUserCacheWriter userCacheWriter) { public async Task HandleAsync( UserLoggedInEvent @event, CancellationToken cancellationToken) { var user = await identityProvider .GetByIdAsync(@event.UserId, cancellationToken) .ConfigureAwait(false); if (user is null) return; // Only DisplayName and Email are cached -- no address, no phone, no SSN. await userCacheWriter.UpsertAsync( @event.UserId, user.DisplayName, user.Email, cancellationToken) .ConfigureAwait(false); } } ``` The identity provider (Keycloak) remains the single source of truth. The application cache is a projection, not a replica. **Endpoint DTOs, never entities.** Granit enforces a strict rule: EF Core entities must never be returned directly from API endpoints. Every response goes through a `*Response` record. This is not just a code style preference — it is a data minimization mechanism. The response record declares exactly which fields leave the system boundary. Internal fields like `TenantId`, `DeletedBy`, or `IsProcessingRestricted` never appear in the API surface unless explicitly mapped. ## Right to erasure: soft delete as the default, hard delete as the exception [Section titled “Right to erasure: soft delete as the default, hard delete as the exception”](#right-to-erasure-soft-delete-as-the-default-hard-delete-as-the-exception) GDPR Article 17 grants data subjects the right to erasure. The naive implementation is `DELETE FROM`. The correct implementation depends on your regulatory context. In healthcare and finance, you often need to retain records for legal periods while making them invisible to normal operations. Granit handles this with a two-tier strategy. ### Tier 1: Soft delete for operational invisibility [Section titled “Tier 1: Soft delete for operational invisibility”](#tier-1-soft-delete-for-operational-invisibility) Any entity implementing `ISoftDeletable` is never physically deleted during normal operations. The `SoftDeleteInterceptor` intercepts EF Core’s `SaveChanges` call and converts every `DELETE` into an `UPDATE`: ```plaintext DELETE FROM Patients WHERE Id = @id -- intercepted -- UPDATE Patients SET IsDeleted = true, DeletedAt = @now, DeletedBy = @userId WHERE Id = @id ``` A global query filter then excludes soft-deleted records from all queries: ApplyGranitConventions — automatic filter ```csharp // Applied by ApplyGranitConventions for every ISoftDeletable entity // Expression: e => !e.IsDeleted ``` The developer does not write `WHERE IsDeleted = false`. The developer does not remember to check deletion status. **The framework handles it.** Every query, every navigation property, every `Include` — all filtered automatically. When you need to see deleted records (admin panel, audit trail, GDPR export), you disable the filter explicitly: PatientAdminService.cs ```csharp public class PatientAdminService(IDataFilter dataFilter, AppDbContext db) { public async Task> GetAllIncludingDeletedAsync( CancellationToken cancellationToken) { using (dataFilter.Disable()) { return await db.Patients .ToListAsync(cancellationToken) .ConfigureAwait(false); } } } ``` The `using` block makes the intent explicit and auditable. The filter re-enables automatically when the scope exits. ### Tier 2: Hard delete for actual erasure [Section titled “Tier 2: Hard delete for actual erasure”](#tier-2-hard-delete-for-actual-erasure) Soft delete is not erasure. GDPR Article 17 requires actual deletion when there is no legal basis for retention. Granit.Privacy provides a **deletion saga** that orchestrates hard deletion across all registered data providers: ```plaintext User -> API: DELETE /privacy/my-data API -> Saga: PersonalDataDeletionRequestedEvent Saga -> Identity: DeleteByIdAsync (hard delete) Saga -> BlobStorage: Delete user blobs Saga -> Notifications: Delete delivery records Saga -> User: Acknowledgment notification ``` Each module registers itself as a data provider during startup. When a deletion request arrives, the saga queries all registered providers and waits for each to confirm completion. No module is forgotten, because registration is mandatory for modules that hold personal data. This two-tier approach satisfies both requirements: operational invisibility during normal use (soft delete), and provable erasure when a data subject exercises their rights (hard delete via saga). ## Processing restriction: GDPR Article 18 [Section titled “Processing restriction: GDPR Article 18”](#processing-restriction-gdpr-article-18) Between “active” and “deleted” there is a third state that many frameworks ignore: **restricted processing**. GDPR Article 18 allows data subjects to request that their data be kept but not processed — for example, while a complaint is being investigated. Granit models this with the `IProcessingRestrictable` interface. Entities implementing it gain an `IsProcessingRestricted` boolean, and `ApplyGranitConventions` adds a query filter that excludes them from normal queries: Entity with processing restriction support ```csharp public class Patient : AuditedEntity, ISoftDeletable, IProcessingRestrictable, IMultiTenant { public string Name { get; set; } = string.Empty; public bool IsDeleted { get; set; } public DateTimeOffset? DeletedAt { get; set; } public string? DeletedBy { get; set; } public bool IsProcessingRestricted { get; set; } public Guid? TenantId { get; set; } } ``` The query filter (`!e.IsProcessingRestricted`) ensures that restricted records are invisible to business logic without being deleted. An admin can lift the restriction later, and the data reappears. No restore operation, no backup retrieval — just a flag change. This is a subtle but important distinction from soft delete. Soft-deleted data is gone from the application’s perspective. Processing-restricted data is preserved but frozen. Both states are enforced at the query filter level, which means application code cannot accidentally process restricted records. ## Pseudonymization: Vault Transit encryption [Section titled “Pseudonymization: Vault Transit encryption”](#pseudonymization-vault-transit-encryption) GDPR Article 25 recommends **pseudonymization** as a technical measure for data protection by design. Granit implements this through HashiCorp Vault’s Transit secret engine via `ITransitEncryptionService`. The Transit engine performs encryption and decryption without ever exposing the encryption key to the application. The key lives in Vault. The application sends plaintext, receives ciphertext, and stores the ciphertext. If the database is compromised, the attacker gets `vault:v1:AbCdEf...` — useless without access to Vault. SensitiveDataService.cs ```csharp public class SensitiveDataService(ITransitEncryptionService transit) { public async Task ProtectSsnAsync( string ssn, CancellationToken cancellationToken) { return await transit.EncryptAsync( "pii-data", ssn, cancellationToken).ConfigureAwait(false); // Returns: "vault:v1:AbCdEf..." } public async Task RevealSsnAsync( string encryptedSsn, CancellationToken cancellationToken) { return await transit.DecryptAsync( "pii-data", encryptedSsn, cancellationToken).ConfigureAwait(false); } } ``` **Key rotation** happens in Vault, not in application code. You rotate the key in Vault, and new encryptions use the new key version. Old ciphertexts remain decryptable because Vault retains previous key versions. You can also re-encrypt existing ciphertexts with the new key version via a batch operation — no application downtime, no data migration. For local development, `GranitVaultModule` is automatically disabled in `Development` environments. The application falls back to the local AES-256-CBC provider from `Granit.Encryption`, so developers do not need a Vault server on their machine. The `IStringEncryptionService` abstraction supports provider switching at runtime via configuration: | Provider | Config value | Backend | | ------------- | ------------ | -------------------------------------------- | | AES (default) | `"Aes"` | Local AES-256-CBC with PBKDF2 key derivation | | Vault Transit | `"Vault"` | HashiCorp Vault Transit engine | This separation means the same code works in development (AES) and production (Vault) without conditional logic. ## Audit trail: proving what happened [Section titled “Audit trail: proving what happened”](#audit-trail-proving-what-happened) GDPR Articles 5(2) and 24 require **accountability** — you must be able to demonstrate compliance. ISO 27001 Annex A.8.15 requires logging of user activities. Granit implements this at two levels. **Entity-level audit** via `AuditedEntityInterceptor`. Every entity that extends `AuditedEntity` gets automatic tracking of `CreatedBy`, `CreatedAt`, `ModifiedBy`, and `ModifiedAt`. The interceptor resolves the current user from `ICurrentUserService` and the timestamp from `IClock` (never `DateTime.UtcNow`). These fields are set on every `SaveChanges` call — the developer cannot forget, and the developer cannot forge the timestamp. | Entity state | Interceptor action | | ------------ | --------------------------------------------------------------------------------- | | `Added` | Sets `CreatedAt`, `CreatedBy`. Auto-generates `Id`. Injects `TenantId`. | | `Modified` | Protects `CreatedAt`/`CreatedBy` from overwrite. Sets `ModifiedAt`, `ModifiedBy`. | **Event-level audit** via `Granit.Timeline`. While entity-level audit tells you *what* changed and *when*, the timeline tells you *why*. Timeline entries capture business events: “Patient consent withdrawn”, “Document approved”, “Export requested”. These entries form the audit trail that an ISO 27001 auditor can review without querying raw database tables. The combination of entity audit (automatic, structural) and timeline (explicit, semantic) gives you both the technical proof and the business narrative required for compliance. ## Tenant isolation: data cannot leak [Section titled “Tenant isolation: data cannot leak”](#tenant-isolation-data-cannot-leak) Multi-tenancy is a GDPR concern. If Tenant A’s data appears in Tenant B’s query results, that is a data breach — full stop. Granit enforces tenant isolation through a global query filter on all `IMultiTenant` entities. The filter expression `e.TenantId == currentTenant.Id` is applied by `ApplyGranitConventions` and evaluated on every query. There is no way to accidentally query across tenants without explicitly disabling the filter. `Granit.BlobStorage` takes this further. When blob storage is configured with multi-tenancy, it **throws an exception** if no tenant context is available. You cannot upload or download a file without an active tenant. This is a deliberate design choice: in a GDPR context, it is better to fail loudly than to store a file without tenant attribution. Tenant isolation is structural, not optional ```csharp // This entity is automatically filtered by TenantId -- no manual WHERE clause. public class MedicalRecord : AuditedEntity, IMultiTenant, ISoftDeletable { public Guid? TenantId { get; set; } public bool IsDeleted { get; set; } public DateTimeOffset? DeletedAt { get; set; } public string? DeletedBy { get; set; } // ... } ``` The `ICurrentTenant` abstraction uses a null object pattern (`NullTenantContext` with `IsAvailable = false`) when multi-tenancy is not installed. Modules check `IsAvailable` before using `Id`, which means the same code works in single-tenant and multi-tenant deployments without conditional branching. ## Cookie consent: no undeclared cookies [Section titled “Cookie consent: no undeclared cookies”](#cookie-consent-no-undeclared-cookies) GDPR requires informed consent for non-essential cookies. Most frameworks leave cookie management to the application. Granit inverts this with a **strict registry pattern** in `Granit.Cookies`. Every cookie must be declared at startup with its category, retention period, and purpose. Writing an undeclared cookie throws `UnregisteredCookieException` when strict mode is enabled. This means a developer cannot silently introduce a tracking cookie — the framework rejects it at runtime. Cookie registration — compile-time documentation of all cookies ```csharp cookies.RegisterCookie(new CookieDefinition( Name: "session_id", Category: CookieCategory.StrictlyNecessary, RetentionDays: 1, IsHttpOnly: true, Purpose: "Session identification")); ``` The `Granit.Cookies.Klaro` package integrates with the Klaro consent management platform (EU-sovereign, open-source) to enforce consent before setting non-essential cookies. Four categories map directly to GDPR requirements: | Category | Consent required | Examples | | ------------------- | ---------------- | ----------------------------- | | `StrictlyNecessary` | No | Session, CSRF, authentication | | `Functionality` | Yes | Preferences, language | | `Analytics` | Yes | Usage tracking | | `Marketing` | Yes | Advertising, retargeting | The cookie registry doubles as living documentation. During an audit, you can enumerate every cookie your application sets, its purpose, its retention period, and its consent category — programmatically. ## The pattern: framework constraints over application discipline [Section titled “The pattern: framework constraints over application discipline”](#the-pattern-framework-constraints-over-application-discipline) Every privacy feature described above shares a common design principle: **make the right thing automatic and the wrong thing difficult.** * Soft delete is an interceptor, not a convention. You cannot forget it. * Query filters are applied by `ApplyGranitConventions`, not by individual queries. You cannot skip them. * Audit fields are set by an interceptor, not by application code. You cannot forge them. * Tenant isolation is a query filter, not a `WHERE` clause. You cannot omit it. * Cookie registration is enforced at runtime, not documented in a wiki. You cannot bypass it. GDPR compliance is not achieved by training developers to remember the rules. It is achieved by building a framework where the rules are the default behavior, and violating them requires explicit, auditable action. ## Further reading [Section titled “Further reading”](#further-reading) * [Granit.Privacy module reference](/reference/modules/privacy/) — data export saga, deletion orchestration, legal agreements * [Granit.Vault & Encryption reference](/reference/modules/vault-encryption/) — Transit encryption, dynamic credentials, provider switching * [Granit.Persistence reference](/reference/modules/persistence/) — interceptors, soft delete, query filters, audit trail * [Security model](/concepts/security-model/) — authentication, authorization, and compliance architecture # [GeneratedRegex] Is Not Optional — Compiled Regex Is Dead > new Regex(..., RegexOptions.Compiled) allocates at startup and hides patterns from the JIT. Source-generated regex is faster, safer, and enforced at build time. You ship a validation library. It runs 200,000 regex matches per second against user input — email addresses, phone numbers, tax identifiers. Each `new Regex(..., RegexOptions.Compiled)` allocates IL at startup, blocks the first request, and makes the pattern invisible to static analysis. Meanwhile, a carefully crafted input triggers **catastrophic backtracking** and your API hangs for 30 seconds. The root cause is the same every time: **runtime-compiled regex with no timeout**. ## The problem [Section titled “The problem”](#the-problem) `RegexOptions.Compiled` generates IL at runtime. That sounds fast, but it comes with real costs: * **Cold-start penalty** — the JIT compiles the pattern on first use, blocking the calling thread. * **No static analysis** — the pattern is an opaque string. The compiler cannot verify it, warn about syntax errors, or optimize it. * **No AOT support** — `Compiled` is incompatible with Native AOT. If you target `PublishAot`, those regex calls silently fall back to interpreted mode. * **No ReDoS protection by default** — without an explicit timeout, a malicious input can pin a thread indefinitely. EmailValidator.cs — Don't do this ```csharp public static class EmailValidator { // Allocated at class load, JIT-compiled at first call, no timeout private static readonly Regex EmailPattern = new( @"^[a-zA-Z0-9._%+\-]+@([a-zA-Z0-9\-]+\.)+[a-zA-Z]{2,}$", RegexOptions.Compiled); public static bool IsValid(string email) => EmailPattern.IsMatch(email); } ``` This pattern has been the default recommendation since .NET Framework. It is no longer the right choice. ## The fix: `[GeneratedRegex]` [Section titled “The fix: \[GeneratedRegex\]”](#the-fix-generatedregex) .NET 7 introduced **source-generated regex**. The Roslyn source generator emits a purpose-built `Regex` subclass at compile time — no runtime IL generation, no startup cost, full AOT compatibility. EmailValidator.cs — Do this instead ```csharp public static partial class EmailValidator { [GeneratedRegex(@"^[a-zA-Z0-9._%+\-]+@([a-zA-Z0-9\-]+\.)+[a-zA-Z]{2,}$", RegexOptions.None, 100)] private static partial Regex EmailPattern(); public static bool IsValid(string email) => EmailPattern().IsMatch(email); } ``` Three differences, all of them improvements: * **Compile-time generation** — the pattern is parsed and optimized during build. A malformed pattern produces a build error, not a runtime `ArgumentException`. * **Zero cold-start cost** — the generated code is a regular method. No IL emission, no JIT surprise on the first request. * **Explicit timeout** — the third argument (`100`) sets a **100ms match timeout**. If a pathological input triggers backtracking, the engine throws `RegexMatchTimeoutException` instead of hanging. ## How Granit uses it [Section titled “How Granit uses it”](#how-granit-uses-it) Every regex in the Granit framework follows this pattern. The validation library is a good example: ContactValidatorExtensions.cs ```csharp [GeneratedRegex(@"^[a-zA-Z0-9._%+\-]+@([a-zA-Z0-9\-]+\.)+[a-zA-Z]{2,}$", RegexOptions.None, 100)] private static partial Regex EmailRegex(); [GeneratedRegex(@"^\+[1-9]\d{6,14}$", RegexOptions.None, 100)] private static partial Regex E164Regex(); ``` BicSwiftAlgorithm.cs ```csharp [GeneratedRegex(@"^[A-Z]{4}[A-Z]{2}[A-Z0-9]{2}([A-Z0-9]{3})?$", RegexOptions.None, 100)] private static partial Regex BicRegex(); ``` Even architecture tests — where performance is less critical — use `[GeneratedRegex]` for consistency: ArchitectureTests.cs ```csharp [GeneratedRegex(@"(?<=^[ \t]+(?:(?:public|private|protected|internal|static|override|sealed|virtual|new)\s+)*)async\s+void\s+\w+", RegexOptions.Multiline)] private static partial Regex AsyncVoidMethod(); ``` The convention is simple: **if it is a regex, it is `[GeneratedRegex]`**. No exceptions, no “it is only used once so it does not matter”. ## The timeout matters [Section titled “The timeout matters”](#the-timeout-matters) The third parameter deserves its own mention. **ReDoS** (Regular Expression Denial of Service) is a real attack vector. A pattern like `(a+)+$` matched against `aaaaaaaaaaaaaaaaX` causes exponential backtracking. Without a timeout, a single HTTP request can consume a thread for minutes. Granit enforces a **100ms timeout on every regex that processes user input**. This is enough for any legitimate match and short enough to abort an attack before it causes damage. Internal-only patterns (like the architecture test above) may omit the timeout when the input is trusted source code, but the default stance is clear: **set a timeout unless you can prove the input is safe**. ## Key takeaways [Section titled “Key takeaways”](#key-takeaways) * **Never use `new Regex(..., RegexOptions.Compiled)`**. It is slower to start, invisible to static analysis, and incompatible with AOT. * **Always use `[GeneratedRegex]`** on a `static partial` method. The source generator handles optimization at build time. * **Always set a timeout** (third parameter) when the regex processes user input. 100ms is a sensible default. * **Make the class `partial`**. The source generator needs a partial class to emit the implementation. This is the most common mistake when migrating. ## Further reading [Section titled “Further reading”](#further-reading) * [Core & Utilities reference](/reference/modules/core/) — validation extensions, regex conventions * [FluentValidation (ADR-006)](/architecture/adr/006-fluentvalidation/) — how Granit integrates FluentValidation with generated regex # Guard Clauses Done Right > Manual null checks are noisy and inconsistent. .NET provides built-in guard methods that are cleaner, faster, and throw the right exceptions every time. Every .NET codebase has dozens of methods that start with the same ritual: check a parameter, throw if it is null, repeat. The code is correct but **verbose, inconsistent, and easy to get wrong**. Since .NET 6, there is a better way — and Granit enforces it across all 100+ packages. ## The problem [Section titled “The problem”](#the-problem) Manual null checks are boilerplate. They are also a source of subtle bugs: typos in `nameof()`, wrong exception types, or checks that simply get forgotten during a late-night commit. TenantService.cs — Don't do this ```csharp public class TenantService { public async Task GetTenantAsync(string tenantId, IDbConnection connection) { if (tenantId == null) throw new ArgumentNullException(nameof(tenantId)); if (string.IsNullOrEmpty(tenantId)) throw new ArgumentException("Value cannot be empty.", nameof(tenantId)); if (connection == null) throw new ArgumentNullException(nameof(connection)); // Business logic starts here — after 8 lines of ceremony. return await connection.QuerySingleAsync(tenantId); } } ``` Three parameters, eight lines of guard code. Every developer writes these slightly differently: some use `is null`, some use `== null`, some throw `ArgumentException` for empty strings, some do not. The inconsistency adds up. Worse, if you rename a parameter and forget to update the `nameof()`, the exception message points to a parameter that no longer exists. The compiler will not catch it. ## The fix: built-in ThrowIf\* methods [Section titled “The fix: built-in ThrowIf\* methods”](#the-fix-built-in-throwif-methods) .NET ships a family of **static guard methods** on the exception types themselves. They validate the argument and throw the correct exception with the correct parameter name — automatically. TenantService.cs — Do this instead ```csharp public class TenantService { public async Task GetTenantAsync(string tenantId, IDbConnection connection) { ArgumentException.ThrowIfNullOrEmpty(tenantId); ArgumentNullException.ThrowIfNull(connection); return await connection.QuerySingleAsync(tenantId); } } ``` Two lines instead of eight. No `nameof()`. No chance of a mismatch. ## How it works: CallerArgumentExpression [Section titled “How it works: CallerArgumentExpression”](#how-it-works-callerargumentexpression) The magic behind these methods is **`[CallerArgumentExpression]`**, a compiler attribute introduced in C# 10. When you write: ```csharp ArgumentNullException.ThrowIfNull(connection); ``` The compiler rewrites it at compile time to pass `"connection"` as the parameter name. You get the correct name in the exception message without writing it yourself. Rename the variable, and the exception message updates automatically. ## The full ThrowIf\* family [Section titled “The full ThrowIf\* family”](#the-full-throwif-family) .NET provides guard methods for the most common precondition checks. Here is the complete set available in .NET 10: | Method | Throws | Use case | | --------------------------------------------------------------------- | --------------------------------------------- | ------------------------------------- | | `ArgumentNullException.ThrowIfNull(value)` | `ArgumentNullException` | Null reference or nullable value type | | `ArgumentException.ThrowIfNullOrEmpty(value)` | `ArgumentNullException` / `ArgumentException` | Null or empty string | | `ArgumentException.ThrowIfNullOrWhiteSpace(value)` | `ArgumentNullException` / `ArgumentException` | Null, empty, or whitespace string | | `ArgumentOutOfRangeException.ThrowIfZero(value)` | `ArgumentOutOfRangeException` | Numeric zero | | `ArgumentOutOfRangeException.ThrowIfNegative(value)` | `ArgumentOutOfRangeException` | Negative number | | `ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value)` | `ArgumentOutOfRangeException` | Zero or negative | | `ArgumentOutOfRangeException.ThrowIfGreaterThan(value, other)` | `ArgumentOutOfRangeException` | Value exceeds upper bound | | `ArgumentOutOfRangeException.ThrowIfLessThan(value, other)` | `ArgumentOutOfRangeException` | Value below lower bound | | `ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(value, other)` | `ArgumentOutOfRangeException` | Value at or above bound | | `ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(value, other)` | `ArgumentOutOfRangeException` | Value at or below bound | | `ArgumentOutOfRangeException.ThrowIfEqual(value, other)` | `ArgumentOutOfRangeException` | Value equals a forbidden value | | `ArgumentOutOfRangeException.ThrowIfNotEqual(value, other)` | `ArgumentOutOfRangeException` | Value does not equal expected | | `ObjectDisposedException.ThrowIf(condition, instance)` | `ObjectDisposedException` | Object already disposed | These cover the vast majority of parameter validation. For domain-specific preconditions (entity not found, invalid state transitions), Granit uses **semantic exceptions** like `NotFoundException` and `BusinessException` instead — see the [Guard Clause pattern page](/architecture/patterns/guard-clause/). ## A real-world example from Granit [Section titled “A real-world example from Granit”](#a-real-world-example-from-granit) Here is how `ThrowIf*` methods look in practice, taken from a typical Granit service: BlobStorageService.cs ```csharp public async Task UploadAsync( string containerName, Stream content, string fileName, string? contentType = null, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(containerName); ArgumentNullException.ThrowIfNull(content); ArgumentException.ThrowIfNullOrEmpty(fileName); ArgumentOutOfRangeException.ThrowIfZero(content.Length); // All preconditions validated — business logic starts clean. var descriptor = new BlobDescriptor(containerName, fileName, contentType); await _storage.PutAsync(descriptor, content, cancellationToken); return descriptor; } ``` Four guards, four lines. Each one throws the **right exception type** with the **right parameter name**. No `nameof()`, no string literals, no room for error. ## Why it matters [Section titled “Why it matters”](#why-it-matters) This is not just about saving keystrokes. The consistency has real benefits: * **Correct exception types** — `ThrowIfNullOrEmpty` throws `ArgumentNullException` for null and `ArgumentException` for empty. Manual code often gets this wrong. * **Refactoring-safe** — `CallerArgumentExpression` tracks the parameter name at compile time. Rename freely. * **Performance** — these methods are implemented with `[StackTraceHidden]` and aggressive inlining. The JIT optimizes the non-throwing path to near zero overhead. * **Consistency** — across 93 Granit packages, every guard clause looks the same. New contributors read one, they have read them all. ## Further reading [Section titled “Further reading”](#further-reading) * [Core & Utilities reference](/reference/modules/core/) — Module system, shared domain types, guard patterns * [Guard Clause pattern](/architecture/patterns/guard-clause/) — Semantic exceptions and RFC 7807 ProblemDetails # Introducing Granit: A Modular Framework for .NET 10 > Granit is an open-source modular framework for .NET 10 — 100+ packages, zero circular dependencies, GDPR and ISO 27001 compliance built in. Every .NET team rebuilds the same infrastructure. Authentication, authorization, audit trails, multi-tenancy, notification dispatch, file storage, localization, feature flags, document generation — the list is long and the implementations are always slightly different, slightly incomplete, and never quite production-ready on day one. We spent years watching this pattern repeat across projects before deciding to stop rebuilding and start composing. Granit is the result. It is an open-source modular framework for **.NET 10** that packages 93 production-ready NuGet packages into a strict dependency graph with **zero circular references**. You install only what you need. Every package enforces GDPR and ISO 27001 constraints by default, so compliance is not an afterthought bolted on before an audit — it is baked into the interceptors, the query filters, and the encryption layer from the start. ## Why another framework? [Section titled “Why another framework?”](#why-another-framework) The .NET ecosystem has mature building blocks — Entity Framework Core, ASP.NET Core Minimal APIs, FluentValidation, Serilog, OpenTelemetry. What it lacks is **opinionated composition**. How should those libraries work together in a modular monolith? How do you enforce tenant isolation across 20 modules without every developer remembering to add a `WHERE TenantId = @tenantId` clause? How do you guarantee that every entity mutation produces an audit trail entry? Granit answers these questions with **conventions over configuration**. You declare your module, list its dependencies, and the framework handles the cross-cutting concerns: audit interceptors, soft-delete query filters, tenant isolation, encryption at rest, structured logging. Your team writes domain logic instead of infrastructure glue. This is not a full-stack application template. It is not a code generator. It is a **framework of composable, independently versioned packages** that you pull in through NuGet and configure through a module system inspired by ABP’s architecture — but built from scratch for .NET 10, C# 14, and modern deployment targets. ## Core principles [Section titled “Core principles”](#core-principles) ### Modular monolith with an extraction path [Section titled “Modular monolith with an extraction path”](#modular-monolith-with-an-extraction-path) Granit is designed around the [modular monolith architecture](/blog/why-modular-monolith/). Every module owns its data through an **isolated DbContext**, communicates with other modules through well-defined interfaces or in-process messaging (Wolverine), and declares its dependencies explicitly through `[DependsOn]` attributes. This means two things. First, you get a single deployable unit with the operational simplicity of a monolith. Second, when scaling pressures or team growth justify it, you can extract a module into a standalone service because the boundaries are already enforced — no untangling of shared database tables, no hunting for hidden coupling. ### Enforced boundaries [Section titled “Enforced boundaries”](#enforced-boundaries) The dependency graph is a strict **directed acyclic graph (DAG)**, validated at build time by architecture tests. If module A depends on module B, module B cannot depend on module A — not directly, not transitively. This is not a convention documented in a wiki and ignored under deadline pressure. It is a compilation constraint enforced by `Granit.ArchitectureTests` on every CI run. Each `*.EntityFrameworkCore` package owns its tables and applies query filters through `ApplyGranitConventions`. No module reads another module’s tables directly. If module A needs data from module B, it goes through B’s public interface. This makes module extraction a deployment decision, not an architecture decision. ### Compliance by default [Section titled “Compliance by default”](#compliance-by-default) Every Granit application gets **GDPR** and **ISO 27001** compliance out of the box: * **Audit trail**: `AuditedEntityInterceptor` records who created and modified every entity, when, and from which tenant context. No opt-in required — if your entity inherits from `AuditedEntity`, it is audited. * **Soft delete**: `SoftDeleteInterceptor` converts `DELETE` operations into `IsDeleted = true` updates and applies global query filters so deleted records are invisible to normal queries. This satisfies the right to erasure while preserving referential integrity for audit purposes. * **Encryption at rest**: `Granit.Vault.HashiCorp` integrates with HashiCorp Vault’s Transit engine, and `Granit.Vault.Azure` with Azure Key Vault, for field-level encryption. Sensitive data — national identification numbers, health records, financial details — is encrypted before it reaches the database. * **Privacy markers**: `IProcessingRestrictable` lets you freeze processing on a per-record basis, satisfying GDPR Article 18 (right to restriction of processing). * **Tenant isolation**: Multi-tenant query filters ensure that tenant A never sees tenant B’s data, even if a developer forgets to filter manually. ## What you get [Section titled “What you get”](#what-you-get) Granit is organized into package groups. You can install a single package or pull in a **bundle** that brings a curated set of packages for common scenarios. * Core & Utilities The foundation layer provides the module system, domain base types, and cross-cutting utilities. | Package | What it does | | -------------------------- | -------------------------------------------------------------------------------------------------------------- | | `Granit.Core` | Module system (`GranitModule`, `[DependsOn]`), shared domain types (`Entity`, `AggregateRoot`, `ValueObject`) | | `Granit.Timing` | `IClock` abstraction and `TimeProvider` integration — [never use DateTime.Now](/blog/stop-using-datetime-now/) | | `Granit.Guids` | `IGuidGenerator` with sequential GUIDs for clustered index performance | | `Granit.Validation` | FluentValidation integration with international validators | | `Granit.Validation.Europe` | France/Belgium-specific validators (NISS, SIREN, VAT, RIB) | | `Granit.Analyzers` | Custom Roslyn analyzers and code fixes that enforce Granit conventions at compile time | * Security & Identity Authentication, authorization, encryption, and identity management — all integrated with Keycloak as the default identity provider. | Package | What it does | | --------------------------------- | ------------------------------------------------------------------------------------- | | `Granit.Security` | `ICurrentUserService`, security abstractions | | `Granit.Authentication.JwtBearer` | JWT Bearer middleware with Keycloak token validation | | `Granit.Authentication.Keycloak` | Claims transformation for Keycloak tokens | | `Granit.Authorization` | Policy-based authorization with EF Core store | | `Granit.Vault` | Vault abstractions — transit encryption, dynamic credentials | | `Granit.Vault.HashiCorp` | HashiCorp Vault — Transit encryption, dynamic DB credentials | | `Granit.Vault.Azure` | Azure Key Vault — RSA-OAEP-256 encryption, dynamic credentials from Key Vault Secrets | | `Granit.Privacy` | GDPR privacy helpers (processing restriction, data minimization) | | `Granit.Identity` | Identity provider abstractions (`IIdentityProvider`, `IUserLookupService`) | | `Granit.Identity.Keycloak` | Keycloak Admin API implementation with cache-aside user sync | * Data & Persistence Everything related to storing, caching, and querying data — with built-in audit trails and tenant isolation. | Package | What it does | | ---------------------- | ---------------------------------------------------------------------- | | `Granit.Persistence` | EF Core interceptors for audit, soft delete, entity versioning | | `Granit.Caching` | Distributed caching with HybridCache and Redis support | | `Granit.MultiTenancy` | Tenant resolution, isolation, and per-tenant configuration | | `Granit.Settings` | Application settings with EF Core store and change tracking | | `Granit.Features` | Feature flags — Toggle, Numeric, and Selection types | | `Granit.BlobStorage` | Blob storage with S3, Azure, FileSystem, Database, and Proxy providers | | `Granit.ReferenceData` | i18n reference data with CRUD endpoints | * Messaging & Notifications Wolverine-based messaging, webhook delivery, and a notification engine with six delivery channels. | Package | What it does | | --------------------------------- | ---------------------------------------------------------- | | `Granit.Wolverine` | Wolverine integration with PostgreSQL transactional outbox | | `Granit.Webhooks` | Webhook subscriptions with reliable delivery and retry | | `Granit.Notifications` | Notification engine with fan-out and delivery tracking | | `Granit.Notifications.Email` | Email via SMTP, Brevo, or Azure Communication Services | | `Granit.Notifications.Sms` | SMS via Brevo or Azure Communication Services | | `Granit.Notifications.WhatsApp` | WhatsApp Business API | | `Granit.Notifications.WebPush` | Web Push notifications (VAPID) | | `Granit.Notifications.MobilePush` | Mobile Push — FCM, Azure Notification Hubs | | `Granit.Notifications.SignalR` | Real-time notifications via SignalR | * Documents & Data Exchange Template rendering, document generation, and structured data import/export. | Package | What it does | | --------------------------------- | --------------------------------------------------- | | `Granit.Templating` | Template engine built on Scriban with EF Core store | | `Granit.DocumentGeneration.Pdf` | HTML-to-PDF rendering via PuppeteerSharp | | `Granit.DocumentGeneration.Excel` | Excel generation via ClosedXML | | `Granit.DataExchange` | Import pipeline: Extract, Map, Validate, Execute | | `Granit.DataExchange.Csv` | CSV parsing via Sep (high-performance) | | `Granit.DataExchange.Excel` | Excel parsing via Sylvan.Data.Excel | | `Granit.Workflow` | FSM engine with publication lifecycle and audit | That is a subset. The full catalog includes **100+ packages** covering localization (17 cultures with source-generated keys), background job scheduling (Wolverine + Cronos), API versioning, OpenAPI documentation (Scalar), cookie consent (Klaro), image processing (WebP/AVIF via Magick.NET), timeline and audit events, and more. Browse the complete list in the [module reference](/reference/). ## Getting started [Section titled “Getting started”](#getting-started) Add the Granit meta-packages to your project and wire them up in `Program.cs`: Program.cs ```csharp var builder = WebApplication.CreateBuilder(args); builder.Services.AddGranit(builder.Configuration, granit => { granit.AddEssentials(); // Core, Timing, Guids, Validation, Security granit.AddApi(); // Versioning, OpenAPI (Scalar), ExceptionHandling, CORS granit.AddPersistence(options => { options.UsePostgresql(builder.Configuration.GetConnectionString("Default")); }); granit.AddCaching(options => { options.UseRedis(builder.Configuration.GetConnectionString("Redis")); }); granit.AddNotifications(options => { options.AddEmail(); options.AddSignalR(); }); granit.AddLocalization(); // 17 cultures, source-generated keys granit.AddMultiTenancy(); // Tenant isolation via query filters }); var app = builder.Build(); app.UseGranit(); app.Run(); ``` Each `Add*` call registers the module’s services, interceptors, and middleware. You do not configure each library individually — Granit’s module system composes them and resolves the dependency graph. To create your own module, define a class inheriting from `GranitModule` and declare its dependencies: InvoicingModule.cs ```csharp [DependsOn(typeof(GranitPersistenceModule))] [DependsOn(typeof(GranitNotificationsModule))] [DependsOn(typeof(GranitTemplatingModule))] public class InvoicingModule : GranitModule { public override void ConfigureServices(ServiceConfigurationContext context) { context.Services.AddScoped(); context.Services.AddGranitValidatorsFromAssemblyContaining(); } } ``` Your module now has access to persistence with audit trails, notification dispatch, and Scriban templates — all correctly configured with tenant isolation, soft delete filters, and structured logging. The framework handles the plumbing; your team handles the business logic. ## The dependency graph is law [Section titled “The dependency graph is law”](#the-dependency-graph-is-law) One principle deserves extra emphasis: **the dependency graph is not advisory**. Every Granit module declares its dependencies through `[DependsOn]` attributes, and every `` in the solution is validated against architecture tests that run on every CI build. ``` graph TD A[Your Module] --> P[Granit.Persistence] A --> N[Granit.Notifications] P --> C[Granit.Core] P --> T[Granit.Timing] P --> G[Granit.Guids] P --> S[Granit.Security] N --> C N --> W[Granit.Wolverine] style A fill:#fff3e0,stroke:#f57c00,color:#e65100 style C fill:#e8f5e9,stroke:#388e3c,color:#1b5e20 ``` If you introduce a circular dependency — even an indirect one through three intermediate packages — the build fails. This is intentional. Circular dependencies are the leading cause of “big ball of mud” architectures, and catching them at compile time is cheaper than discovering them during a module extraction six months later. ## Localization at scale [Section titled “Localization at scale”](#localization-at-scale) Granit ships with support for **17 cultures**: 14 base languages (English, French, Dutch, German, Spanish, Italian, Portuguese, Chinese, Japanese, Polish, Turkish, Korean, Swedish, Czech) and 3 regional variants (fr-CA, en-GB, pt-BR). Regional variant files only contain keys that differ from the base language — Canadian French inherits from French by default and overrides only the terms that differ. Localization keys are **source-generated**. A Roslyn source generator reads your `.json` resource files and produces strongly-typed accessor classes. No magic strings, no runtime key mismatches, no missing translations discovered in production. ## Open source [Section titled “Open source”](#open-source) Granit is released under the **Apache-2.0** license. The source code, documentation, architecture tests, and all 100+ packages are open. We chose Apache-2.0 deliberately: it is permissive enough for commercial use while providing patent protection that MIT does not. The repository includes community files (`CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md`) and follows a standard contribution workflow: 1. Fork the repository 2. Create a feature branch from `develop` 3. Ensure all tests pass (`dotnet test`) and code is formatted (`dotnet format --verify-no-changes`) 4. Submit a merge request targeting `develop` We welcome contributions at every level — bug reports, documentation improvements, new validators for `Granit.Validation.Europe`, additional notification channels, and performance optimizations. The architecture tests and CI pipeline will catch structural issues before review, so you can focus on the logic. ## What comes next [Section titled “What comes next”](#what-comes-next) This initial release is the foundation. The roadmap includes: * **Publication to nuget.org** for frictionless package installation * **Project templates** (`dotnet new granit-api`, `dotnet new granit-module`) for quick scaffolding * **Additional identity providers** beyond Keycloak (Entra ID, Cognito — now available) * **GraphQL support** alongside the existing Minimal API and REST patterns * **Performance benchmarks** published on every release to track regression We are building Granit because we believe the .NET ecosystem deserves a modular framework that takes compliance seriously, enforces architectural discipline, and lets teams focus on what makes their application unique instead of rebuilding the same infrastructure for the tenth time. ## Further reading [Section titled “Further reading”](#further-reading) * [Getting started](/getting-started/) — install Granit and build your first module * [Modular monolith vs microservices](/concepts/modular-monolith-vs-microservices/) — the architecture spectrum and where Granit fits * [Why we chose modular monolith](/blog/why-modular-monolith/) — the decision behind the architecture * [Stop using DateTime.Now](/blog/stop-using-datetime-now/) — why Granit enforces `TimeProvider` and `IClock` * [Module reference](/reference/) — the complete package catalog with dependency graphs # Isolated DbContext Per Module: Why and How > Shared DbContexts create hidden coupling between modules. Here is how Granit enforces database isolation per module — and why it makes microservice extraction mechanical. You have fifteen EF Core entity types in a single `AppDbContext`. Notifications reference users. Workflows reference blob storage metadata. Localization overrides sit next to webhook subscriptions. Everything compiles. Everything works. And then you try to extract the notification module into its own service. You discover that removing `NotificationEntity` from the shared context breaks three unrelated migrations. A query filter on `ISoftDeletable` that worked fine in the monolith now throws because the interceptor was registered on the wrong context. The `OnModelCreating` method is 400 lines long, and half of it belongs to modules you are not extracting. This is the **shared DbContext trap**. Granit avoids it entirely by enforcing **one isolated DbContext per module**. ## What “isolated” means in practice [Section titled “What “isolated” means in practice”](#what-isolated-means-in-practice) Each Granit module that persists data owns a dedicated `DbContext` subclass. That context knows about exactly the entities the module owns — nothing more. It has its own migration history, its own schema configuration, and its own connection lifetime. Here is the real `DbContext` from the Localization module: GranitLocalizationOverridesDbContext.cs ```csharp internal sealed class GranitLocalizationOverridesDbContext( DbContextOptions options, ICurrentTenant? currentTenant = null, IDataFilter? dataFilter = null) : DbContext(options) { public DbSet LocalizationOverrides { get; set; } = null!; protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); modelBuilder.ApplyConfiguration(new LocalizationOverrideConfiguration()); modelBuilder.ApplyGranitConventions(currentTenant, dataFilter); } } ``` Notice what is **not** here: no `DbSet`, no `DbSet`, no entity types from other modules. The localization context is a closed unit. It compiles, migrates, and runs independently of every other module in the system. The context is `internal sealed`. No external code can reference it directly. Consumers interact through the module’s public abstractions — never through the `DbContext`. ## The three pillars of the pattern [Section titled “The three pillars of the pattern”](#the-three-pillars-of-the-pattern) Every isolated DbContext in Granit follows a strict checklist. Break any rule and the architecture tests fail. ### 1. Constructor injection of cross-cutting services [Section titled “1. Constructor injection of cross-cutting services”](#1-constructor-injection-of-cross-cutting-services) The constructor accepts `ICurrentTenant?` and `IDataFilter?` as **optional parameters** (defaulting to `null`). This is deliberate: a module should work whether or not multi-tenancy is configured in the host application. `ICurrentTenant` lives in `Granit.Core.MultiTenancy`, not in `Granit.MultiTenancy`. Every module can consume it without taking a hard dependency on the multi-tenancy package. When multi-tenancy is not installed, a `NullTenantContext` with `IsAvailable = false` is injected — the query filter simply does not apply. `IDataFilter` enables runtime filter bypass. An admin endpoint that needs to see soft-deleted records can call `dataFilter.Disable()` within a scope, and the filter is suppressed for that query only. ### 2. ApplyGranitConventions — centralized query filters [Section titled “2. ApplyGranitConventions — centralized query filters”](#2-applygranitconventions--centralized-query-filters) The call to `modelBuilder.ApplyGranitConventions(currentTenant, dataFilter)` at the end of `OnModelCreating` is where the framework applies **global query filters** for five marker interfaces: * **`ISoftDeletable`** — filters out records where `IsDeleted == true` * **`IActive`** — filters out records where `IsActive == false` * **`IProcessingRestrictable`** — filters out GDPR-restricted records * **`IMultiTenant`** — scopes queries to `currentTenant.Id` * **`IPublishable`** — filters out unpublished records The implementation combines all applicable filters into a **single `HasQueryFilter` call per entity type**, joining conditions with `AndAlso`. This is not cosmetic — EF Core silently overwrites previous `HasQueryFilter` calls on the same entity. Calling it twice means only the second filter survives. `ApplyGranitConventions` avoids this bug by design. Each filter follows the pattern `bypass || realCondition`, where `bypass` reads a property on a `FilterProxy` object captured as a constant expression. EF Core evaluates this property on every query execution (it treats simple property access on a `ConstantExpression` as a parameterized value), so toggling `IDataFilter.Disable()` at runtime takes effect immediately without rebuilding the model. **You must never write manual `HasQueryFilter` calls in your entity configurations.** If you do, you will overwrite the filters that `ApplyGranitConventions` already applied. This rule is documented, tested, and enforced by code review. ### 3. Registration through AddGranitDbContext [Section titled “3. Registration through AddGranitDbContext”](#3-registration-through-addgranitdbcontext) Each module registers its DbContext through a single extension method: PersistenceDbContextServiceCollectionExtensions.cs ```csharp public static IServiceCollection AddGranitDbContext( this IServiceCollection services, Action configure) where TContext : DbContext { ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(configure); services.AddDbContextFactory((sp, options) => { configure(options); options.UseGranitInterceptors(sp); }, ServiceLifetime.Scoped); return services; } ``` This method does three things right: * It uses `AddDbContextFactory` with the **`(IServiceProvider, DbContextOptionsBuilder)` overload**, so interceptors can be resolved from DI. * It calls `UseGranitInterceptors(sp)`, which wires the four standard interceptors in the correct order. * It sets `ServiceLifetime.Scoped`, so the factory produces one context per scope (per HTTP request, per message handler). The module’s own service registration becomes a one-liner: LocalizationEntityFrameworkCoreHostApplicationBuilderExtensions.cs ```csharp services.AddGranitDbContext( options => options.UseNpgsql(connectionString)); ``` ## Interceptor ordering matters [Section titled “Interceptor ordering matters”](#interceptor-ordering-matters) `UseGranitInterceptors` adds four interceptors in a fixed sequence: 1. **`AuditedEntityInterceptor`** — sets `CreatedAt`, `CreatedBy`, `ModifiedAt`, `ModifiedBy`, `TenantId`, and `Id` (sequential GUID) on new or modified entities. ISO 27001 compliance. 2. **`VersioningInterceptor`** — assigns `BusinessId` and increments `Version` on `IVersioned` entities. 3. **`DomainEventDispatcherInterceptor`** — collects domain events from entities after `SaveChanges` and dispatches them through Wolverine. 4. **`SoftDeleteInterceptor`** — converts physical deletes to soft deletes for `ISoftDeletable` entities by flipping `IsDeleted = true` and changing the entry state from `Deleted` to `Modified`. **SoftDelete must be last.** It changes the entity state from `Deleted` to `Modified`. If it ran before the audit interceptor, the audit interceptor would see a “modified” record instead of a “deleted” one and write the wrong audit fields. The ordering is not configurable — it is baked into `UseGranitInterceptors` to prevent accidents. Each interceptor is resolved via `GetService()` and silently skipped if not registered. This means a module can call `UseGranitInterceptors` even if the host has not configured all of `Granit.Persistence` — the interceptors that exist are wired, and the rest are no-ops. ## How stores consume the DbContext [Section titled “How stores consume the DbContext”](#how-stores-consume-the-dbcontext) Module stores never inject the `DbContext` directly. They inject `IDbContextFactory` and create short-lived context instances: EfWebhookSubscriptionStore.cs ```csharp internal sealed class EfWebhookSubscriptionStore( IDbContextFactory contextFactory) { public async Task GetOrNullAsync( Guid id, CancellationToken cancellationToken = default) { await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); return await context.WebhookSubscriptions .FirstOrDefaultAsync(s => s.Id == id, cancellationToken); } } ``` This pattern gives you explicit control over the context lifetime. Each method creates a fresh context, executes its query, and disposes it. No ambient context leaking across operations. No stale change trackers accumulating entities from unrelated operations. The store itself is `internal`. External consumers use the module’s public interface (`IWebhookSubscriptionReader`, `IWebhookSubscriptionWriter`). The EF Core dependency is an implementation detail that does not leak into the module’s API surface. ## Why this makes microservice extraction mechanical [Section titled “Why this makes microservice extraction mechanical”](#why-this-makes-microservice-extraction-mechanical) When every module owns its data access in isolation, extracting a module into a standalone service is a checklist, not a redesign: 1. **Copy the module’s `*.EntityFrameworkCore` project** into the new service. 2. **Point its `AddGranitDbContext` call** at the new service’s database connection string. 3. **Run `dotnet ef migrations add Initial`** — the migration contains only the module’s tables because the DbContext only knows about the module’s entities. 4. **Replace cross-module calls** (the ones that went through in-process interfaces) with HTTP or messaging. The module boundaries already tell you exactly which calls cross modules. Step 4 is the only one that requires thought. Steps 1 through 3 are mechanical because the data layer has no cross-module dependencies to untangle. Contrast this with a shared `AppDbContext` approach. Extracting a module means surgically removing entity types from a monolithic context, rewriting migration history, hunting down navigation properties that cross module boundaries, and hoping the remaining query filters still compose correctly. It is the kind of work that gets estimated in weeks and delivered in months. ## The cost and why it is worth paying [Section titled “The cost and why it is worth paying”](#the-cost-and-why-it-is-worth-paying) The isolated DbContext pattern is not free. You pay for it in three ways: * **More DbContext classes.** Granit has 12+ isolated contexts today. Each one is small (typically under 30 lines), but they exist. * **More migration histories.** Each context has its own `__EFMigrationsHistory` table. Schema management tooling must be aware of this. * **No cross-context joins.** You cannot write a LINQ query that joins `LocalizationOverrides` with `WebhookSubscriptions`. If you need data from another module, you go through its public interface. The third point is the most important, and it is a feature, not a bug. Cross-module joins are the number one source of hidden coupling in data access layers. Forbidding them at the type system level (the entities simply do not exist in the other context) is stronger than any code review guideline. The 12 modules that use this pattern in Granit today — Authorization, BackgroundJobs, BlobStorage, DataExchange, Features, Identity, Localization, Notifications, Settings, Templating, Timeline, Webhooks, and Workflow — all follow the same structure. Once you have seen one, you have seen them all. That consistency is the real payoff: onboarding a new developer to any module’s persistence layer takes minutes, not days. ## The mandatory checklist [Section titled “The mandatory checklist”](#the-mandatory-checklist) If you are creating a new `*.EntityFrameworkCore` package in Granit, every item on this list is required: 1. Add a `` to `Granit.Persistence` in the `.csproj`. 2. Accept `ICurrentTenant?` and `IDataFilter?` as optional constructor parameters (default `null`). 3. Call `modelBuilder.ApplyGranitConventions(currentTenant, dataFilter)` at the end of `OnModelCreating`. 4. Register the context via `AddGranitDbContext` — never call `AddDbContextFactory` manually. 5. Add `[DependsOn(typeof(GranitPersistenceModule))]` on the module class. 6. Never write manual `HasQueryFilter` calls — `ApplyGranitConventions` handles all standard filters. 7. Use `Guid? TenantId` for `IMultiTenant` entities (never `string`). Skip any of these and you will get silent filter bugs, missing audit trails, or broken tenant isolation. The checklist exists because each item was learned the hard way. ## Further reading [Section titled “Further reading”](#further-reading) * [Persistence module reference](/reference/modules/persistence/) — full API documentation for `AddGranitDbContext`, `UseGranitInterceptors`, and `ApplyGranitConventions` * [Layered architecture pattern](/architecture/patterns/layered-architecture/) — how modules are structured and why the EF Core layer is always an internal implementation detail # Never Return Your EF Entities From an API > Returning EF Core entities directly from your endpoints leaks internal structure, creates circular references, and exposes sensitive data. Use response records instead. Your API returns a user object. The frontend team notices a `PasswordHash` field in the JSON. A penetration tester finds that posting back the payload overwrites the `Role` property. A schema change in your database breaks every consumer. The root cause is always the same: **you returned your EF Core entity directly from an endpoint**. ## The problem [Section titled “The problem”](#the-problem) EF Core entities are persistence concerns. They carry navigation properties, shadow properties, change-tracking baggage, and fields that exist for the database, not for your API consumers. Returning them directly causes four distinct problems: * **Data leakage** — sensitive columns (`PasswordHash`, `DeletedAt`, `TenantId`, internal flags) end up in the JSON response. You are one forgotten property away from a security incident. * **Circular references** — navigation properties (`Order.Customer.Orders.Customer...`) cause `JsonException` at runtime or infinite loops with lenient serializers. * **Lazy loading traps** — if lazy loading is enabled, serializing an entity triggers N+1 queries you never intended. Your “simple GET” suddenly fires 200 SQL statements. * **Schema coupling** — your API contract is now your database schema. Rename a column, add a field, normalize a table, and every consumer breaks. You cannot evolve one without the other. ## Bad practice: returning the entity [Section titled “Bad practice: returning the entity”](#bad-practice-returning-the-entity) GetInvoiceEndpoint.cs — Don't do this ```csharp app.MapGet("/api/v1/invoices/{id:guid}", async ( Guid id, AppDbContext db, CancellationToken ct) => { var invoice = await db.Invoices .Include(i => i.Customer) .Include(i => i.Lines) .FirstOrDefaultAsync(i => i.Id == id, ct); return invoice is null ? Results.NotFound() : Results.Ok(invoice); // Serializes the entire entity graph }); ``` This endpoint returns everything: the `Customer` navigation with all its properties, every `InvoiceLine` with internal pricing flags, the `TenantId`, the `DeletedAt` timestamp. The OpenAPI schema mirrors your database, and any schema migration is now a breaking API change. ## Good practice: response record + explicit mapping [Section titled “Good practice: response record + explicit mapping”](#good-practice-response-record--explicit-mapping) Define a **response record** that represents exactly what the consumer needs. Nothing more. InvoiceResponse.cs ```csharp public sealed record InvoiceResponse( Guid Id, string InvoiceNumber, string CustomerName, DateTimeOffset IssuedAt, DateTimeOffset DueDate, decimal TotalExcludingTax, decimal TotalIncludingTax, IReadOnlyList Lines); public sealed record InvoiceLineResponse( string Description, int Quantity, decimal UnitPrice, decimal LineTotal); ``` Then map explicitly in the endpoint: GetInvoiceEndpoint.cs — Do this instead ```csharp app.MapGet("/api/v1/invoices/{id:guid}", async Task, ProblemHttpResult>> ( Guid id, IInvoiceReader reader, CancellationToken ct) => { var invoice = await reader.FindAsync(id, ct); if (invoice is null) { return TypedResults.Problem("Invoice not found.", statusCode: 404); } var response = new InvoiceResponse( invoice.Id, invoice.InvoiceNumber, invoice.Customer.DisplayName, invoice.IssuedAt, invoice.DueDate, invoice.TotalExcludingTax, invoice.TotalIncludingTax, invoice.Lines.Select(l => new InvoiceLineResponse( l.Description, l.Quantity, l.UnitPrice, l.LineTotal)).ToList()); return TypedResults.Ok(response); }); ``` The entity stays inside the service layer. The API consumer sees a stable, curated contract. ## Naming conventions [Section titled “Naming conventions”](#naming-conventions) Granit enforces strict naming rules for endpoint DTOs to prevent OpenAPI schema collisions: * **`*Request`** for input bodies (`CreateInvoiceRequest`, not `CreateInvoiceDto`). * **`*Response`** for return types (`InvoiceResponse`, not `InvoiceDto`). * **Module prefix** on every DTO. OpenAPI flattens namespaces into a single schema map. `TransitionRequest` from your Workflow module collides with `TransitionRequest` from your Notifications module. Use `WorkflowTransitionRequest` and `NotificationTransitionRequest`. * **Never use the `Dto` suffix.** It says nothing about direction. `Request` and `Response` make intent explicit. * **Shared cross-cutting types** like `PagedResult` and `ProblemDetails` are exempt from the module prefix rule. ## Why it matters [Section titled “Why it matters”](#why-it-matters) **API contract stability.** You can rename database columns, split tables, add internal tracking fields. None of it touches the response record. Your consumers never know. **Security.** The response record is an allowlist. Only the fields you explicitly include are serialized. No `PasswordHash`, no `TenantId`, no soft-delete flags leaking out. This is not defense in depth — it is the primary defense. **OpenAPI schema clarity.** Your generated OpenAPI document describes what consumers actually receive, not the internal shape of your database. Schema names are meaningful (`InvoiceResponse`), not accidental (`Invoice` — is that the entity? the command? the event?). **Testability.** Response records are plain data. You can construct them in tests without a `DbContext`, assert on them with Shouldly, and serialize them predictably. ## Further reading [Section titled “Further reading”](#further-reading) * [CQRS pattern](/architecture/patterns/cqrs/) — why Granit separates Reader and Writer interfaces * [Layered architecture](/architecture/patterns/layered-architecture/) — where entities and response records live # Stop Using DateTime.Now — Inject TimeProvider Instead > DateTime.Now makes your code untestable and timezone-fragile. Here is how to replace it with TimeProvider and IClock for deterministic, test-friendly time. Your tests pass at 2 PM and fail at midnight. A report generates the wrong date for users in Tokyo. A scheduled job fires twice during a DST transition. The root cause is always the same: **a static call to `DateTime.Now` buried somewhere in your business logic**. ## The problem [Section titled “The problem”](#the-problem) `DateTime.Now` and `DateTime.UtcNow` are static. You cannot mock them, swap them, or control them in tests. They also return `DateTime` instead of `DateTimeOffset`, which silently drops timezone information. InvoiceService.cs — Don't do this ```csharp public class InvoiceService { public Invoice CreateInvoice(Order order) { return new Invoice { OrderId = order.Id, IssuedAt = DateTime.UtcNow, // Untestable. What date will the test assert? DueDate = DateTime.UtcNow.AddDays(30), // DST? Timezone? Good luck. }; } } ``` Three problems in two lines: * **Untestable** — you cannot assert on a value that changes every millisecond. * **Timezone-blind** — `DateTime` has no offset. Is this UTC? Local? Server time? * **Non-deterministic** — the same code produces different results depending on when and where it runs. ## The fix: TimeProvider + IClock [Section titled “The fix: TimeProvider + IClock”](#the-fix-timeprovider--iclock) .NET 8 introduced **`TimeProvider`**, a built-in abstraction for time. Granit wraps it in **`IClock`**, which adds timezone-aware conversions for multi-tenant applications. InvoiceService.cs — Do this instead ```csharp public class InvoiceService(IClock clock) { public Invoice CreateInvoice(Order order) { var now = clock.Now; // DateTimeOffset, always UTC return new Invoice { OrderId = order.Id, IssuedAt = now, DueDate = now.AddDays(30), }; } } ``` `IClock.Now` returns a **`DateTimeOffset`** in UTC. No ambiguity, no static coupling. ## How Granit registers it [Section titled “How Granit registers it”](#how-granit-registers-it) `GranitTimingModule` wires everything as singletons — zero configuration needed: TimingServiceCollectionExtensions.cs ```csharp services.TryAddSingleton(TimeProvider.System); services.TryAddSingleton(); services.TryAddSingleton(); ``` `TimeProvider.System` is the standard .NET provider: thread-safe, stateless, production-ready. The `Clock` class delegates to it: Clock.cs ```csharp public sealed class Clock( TimeProvider timeProvider, ICurrentTimezoneProvider timezoneProvider) : IClock { public DateTimeOffset Now => timeProvider.GetUtcNow(); } ``` If your module depends on `GranitTimingModule`, `IClock` is available for injection everywhere — services, handlers, interceptors. ## Testing with FakeTimeProvider [Section titled “Testing with FakeTimeProvider”](#testing-with-faketimeprovider) This is where the pattern pays off. Microsoft ships **`FakeTimeProvider`** in `Microsoft.Extensions.Time.Testing` — a drop-in replacement that lets you control time deterministically. InvoiceServiceTests.cs ```csharp public sealed class InvoiceServiceTests { private readonly FakeTimeProvider _fakeTime = new(); private readonly InvoiceService _sut; public InvoiceServiceTests() { var timezoneProvider = Substitute.For(); var clock = new Clock(_fakeTime, timezoneProvider); _sut = new InvoiceService(clock); } [Fact] public void CreateInvoice_SetsDueDateTo30DaysFromNow() { // Arrange — freeze time var issued = new DateTimeOffset(2026, 3, 1, 10, 0, 0, TimeSpan.Zero); _fakeTime.SetUtcNow(issued); // Act var invoice = _sut.CreateInvoice(new Order { Id = Guid.NewGuid() }); // Assert — deterministic, no flaky test invoice.IssuedAt.ShouldBe(issued); invoice.DueDate.ShouldBe(issued.AddDays(30)); } } ``` No mocking framework needed for time. No `Thread.Sleep`. No “it works if you run it fast enough”. The test is **deterministic**: it passes at 3 AM, on CI, on any timezone. ## Granit enforces it with a Roslyn analyzer [Section titled “Granit enforces it with a Roslyn analyzer”](#granit-enforces-it-with-a-roslyn-analyzer) Forgetting to use `IClock` is easy. Granit makes it hard. The **`DateTimeNowAnalyzer`** (diagnostic `GRSEC001`) flags any direct use of `DateTime.Now`, `DateTime.UtcNow`, or `DateTimeOffset.Now` in your source code. ```plaintext GRSEC001: Use IClock or TimeProvider instead of DateTime.Now ``` The analyzer runs at build time. You cannot accidentally ship code that bypasses the abstraction. ## Key takeaways [Section titled “Key takeaways”](#key-takeaways) * **Never use `DateTime.Now` or `DateTime.UtcNow`** in application code. Inject `IClock` or `TimeProvider` instead. * **`IClock.Now`** always returns `DateTimeOffset` in UTC — no timezone ambiguity. * **`FakeTimeProvider`** makes time-dependent tests deterministic with zero effort. * **Granit enforces this at build time** with the `GRSEC001` Roslyn analyzer. You cannot forget. ## Further reading [Section titled “Further reading”](#further-reading) * [Core & Utilities reference](/reference/modules/core/) — IClock, TimeProvider, IGuidGenerator * [Testing stack (ADR-003)](/architecture/adr/003-testing-stack/) — xUnit, Shouldly, FakeTimeProvider # Why We Chose Modular Monolith Over Microservices > Microservices are not a goal. They are a trade-off. Here is why Granit bets on the modular monolith — and how the framework makes extraction painless when the time comes. Every greenfield .NET project faces the same fork in the road: monolith or microservices? The industry has spent a decade pushing teams toward microservices, often before they need them. Granit takes a deliberate, different stance. ## The architecture spectrum [Section titled “The architecture spectrum”](#the-architecture-spectrum) Architecture is not binary. It is a spectrum with real trade-offs at every point. ``` graph LR A["Monolith"] --> B["Modular Monolith"] B --> C["Microservices"] style A fill:#e8f5e9,stroke:#388e3c,color:#1b5e20 style B fill:#fff3e0,stroke:#f57c00,color:#e65100 style C fill:#ffebee,stroke:#c62828,color:#b71c1c ``` | | Monolith | Modular Monolith | Microservices | | ----------------- | ------------------- | -------------------------------- | ------------------------------- | | Deployment | Single unit | Single unit | Independent per service | | Module boundaries | None or conventions | Enforced in code | Enforced by network | | Data isolation | Shared tables | Isolated DbContext per module | Separate databases | | Communication | Direct calls | In-process channels or messaging | Network (HTTP, gRPC, messaging) | | Operational cost | Low | Low | High | | Team scalability | Limited | Good | High | Moving right on the spectrum adds operational cost: distributed tracing, network failure handling, data consistency challenges, deployment orchestration, contract versioning. That cost is justified only when the benefit — independent scaling, team autonomy, fault isolation — outweighs it. ## The microservices trap [Section titled “The microservices trap”](#the-microservices-trap) Most teams adopt microservices for the wrong reasons: 1. **“We need to scale.”** — In practice, 90% of applications serve fewer than 100k requests per minute. A single well-tuned .NET process handles that comfortably. 2. **“We need team autonomy.”** — Team boundaries do not require network boundaries. Module boundaries enforced at the compiler level provide the same decoupling without the serialization overhead. 3. **“Everyone else does it.”** — Survivorship bias. You hear about Netflix and Uber’s microservices. You don’t hear about the hundreds of startups that collapsed under the weight of premature decomposition. The real cost is rarely discussed: a microservices architecture needs dedicated infrastructure for service discovery, configuration management, distributed tracing, circuit breakers, contract testing, and deployment orchestration. For a team of 5–15 developers, this overhead dwarfs the complexity of the application itself. ## What a modular monolith gives you [Section titled “What a modular monolith gives you”](#what-a-modular-monolith-gives-you) A well-designed modular monolith provides the isolation benefits that matter without the operational tax: ### Compile-time boundary enforcement [Section titled “Compile-time boundary enforcement”](#compile-time-boundary-enforcement) In Granit, every module declares its dependencies explicitly: ```csharp [DependsOn(typeof(GranitPersistenceModule))] [DependsOn(typeof(GranitSecurityModule))] public class InvoiceModule : GranitModule { public override void ConfigureServices(IServiceCollection services) { // This module can only access services from declared dependencies } } ``` Circular dependencies are rejected at startup. Architecture tests enforce the DAG at build time. This gives you the same contract clarity as microservice boundaries — without the network hop. ### Database isolation without separate databases [Section titled “Database isolation without separate databases”](#database-isolation-without-separate-databases) Each module owns an isolated `DbContext`. No shared tables, no accidental coupling: ```csharp public class InvoiceDbContext : DbContext { // Only Invoice entities — cannot access Identity or Notification tables public DbSet Invoices => Set(); public DbSet InvoiceLines => Set(); } ``` When extraction day comes, the database split is mechanical: point the connection string to a new database and run the module’s migrations independently. ### In-process messaging with an upgrade path [Section titled “In-process messaging with an upgrade path”](#in-process-messaging-with-an-upgrade-path) Modules communicate through channels — fast, in-process, zero serialization: ```csharp public static async Task Handle( InvoiceApprovedEvent @event, IMessageBus bus) { // This handler works identically with channels and Wolverine await bus.PublishAsync(new SendInvoiceNotification(@event.InvoiceId)); } ``` The key insight: **the same handler code works with both channels and Wolverine**. When you later need durable messaging with transactional outbox guarantees, add `GranitWolverinePostgresqlModule`. Your business logic stays untouched. ## When to extract [Section titled “When to extract”](#when-to-extract) Granit does not argue against microservices. It argues against premature microservices. The framework is designed so that extraction is a non-event when the trade-off justifies it. * Stay monolith * Fewer than 5 developers on the codebase * Under 100k requests per minute * Single team with a shared release cycle * No module requires independent scaling * Deployment frequency is uniform across modules * Consider extraction * A module has fundamentally different scaling needs (e.g., notification delivery spikes at 10x normal load) * A module has a different deployment cadence (daily vs. weekly) * Team boundaries align with module boundaries and coordination overhead is slowing delivery * Regulatory isolation requires a reduced compliance scope (PCI DSS for payments, HIPAA for health data) * A module failure must not cascade to the rest of the system ## The extraction path [Section titled “The extraction path”](#the-extraction-path) Because Granit enforces the same patterns in both modes, extraction follows a predictable path: 1. **Identify the candidate** — look for modules with scaling, cadence, or isolation requirements that differ from the rest. 2. **Create a new host** — a separate `Program.cs` that composes only the extracted module and its dependencies. 3. **Switch to Wolverine** — replace channel-based messaging with durable Wolverine transport. Handler code does not change. 4. **Split the database** — the module already owns its `DbContext`. Point the connection string to a new database. 5. **Deploy independently** — the module now runs as its own service. No rewrite. No “big bang” migration. One module at a time. ## What we learned [Section titled “What we learned”](#what-we-learned) Building Granit, we studied the architecture choices of teams across dozens of .NET projects. The pattern was consistent: * Teams that started with microservices spent their first year building infrastructure instead of features. * Teams that started with a well-structured modular monolith shipped faster and extracted services only when data proved the need. * The teams that struggled most were those with monoliths that lacked internal boundaries — making later extraction a rewrite. Granit exists to make the modular monolith the path of least resistance: enforce boundaries from day one, make extraction mechanical when needed, and never force a team into operational complexity they haven’t earned. ## Further reading [Section titled “Further reading”](#further-reading) * [Modular Monolith vs Microservices](/concepts/modular-monolith-vs-microservices/) — the full spectrum comparison with migration steps * [Module System](/concepts/module-system/) — how `GranitModule` and `[DependsOn]` work * [Wolverine Optionality](/concepts/wolverine-optionality/) — why Wolverine is optional and how to upgrade # 100+ Packages, Zero Circular Dependencies — How We Enforce a Strict DAG > Granit ships 100+ NuGet packages with zero circular dependencies, enforced by Kahn's algorithm at startup and architecture tests at build time. A framework with 100+ NuGet packages sounds like a dependency management nightmare. At that scale, one careless `ProjectReference` can create a cycle that makes incremental builds impossible, forces unnecessary recompilation, and turns your dependency graph into a tangled mess that no one dares to refactor. Granit has shipped every release with zero circular dependencies. Not because we got lucky, but because the architecture makes cycles structurally impossible at three reinforcing levels: the module system rejects them at startup, architecture tests reject them at build time, and the layered design makes them unlikely in the first place. This article explains how. ## The module system [Section titled “The module system”](#the-module-system) Every Granit package that registers services exposes a **module class** — a sealed subclass of `GranitModule`. The module declares its dependencies explicitly via the `[DependsOn]` attribute: GranitNotificationsModule.cs ```csharp [DependsOn( typeof(GranitGuidsModule), typeof(GranitQueryingModule), typeof(GranitTimingModule))] public sealed class GranitNotificationsModule : GranitModule { public override void ConfigureServices(ServiceConfigurationContext context) => context.Builder.AddGranitNotifications(); } ``` `[DependsOn]` is not documentation. It is a **load-order contract**. At startup, `ModuleLoader` walks the full dependency tree starting from the application’s root module, instantiates every reachable module, and performs a **topological sort using Kahn’s algorithm**. If the sort cannot complete — meaning the number of sorted modules is less than the total — a cycle exists. The application throws `InvalidOperationException` with the names of the modules involved: ModuleLoader.cs (simplified) ```csharp if (sorted.Count != descriptors.Count) { IEnumerable cycleTypes = descriptors.Keys .Except(sorted.Select(d => d.ModuleType)) .Select(t => t.Name); throw new InvalidOperationException( $"Circular dependency detected among modules: {string.Join(", ", cycleTypes)}."); } ``` The application does not start. There is no fallback, no warning, no “best-effort” ordering. A cycle is a hard failure, caught before a single service is registered. This is intentional: cycles that slip past development have a way of becoming permanent. The topological order serves a second purpose. `GranitApplication` iterates over the sorted modules in sequence when calling `ConfigureServices` and `OnApplicationInitialization`. A module can rely on the fact that everything it declared in `[DependsOn]` has already been configured. This eliminates an entire class of “service not registered yet” bugs that plague frameworks with unordered service registration. ## The dependency convention [Section titled “The dependency convention”](#the-dependency-convention) `[DependsOn]` follows a strict convention: **declare direct, non-transitive dependencies only**. If `GranitNotificationsEntityFrameworkCoreModule` depends on `GranitPersistenceModule`, and `GranitPersistenceModule` already depends on `GranitTimingModule`, then `GranitNotificationsEntityFrameworkCoreModule` does not declare `GranitTimingModule`. The transitive dependency is already satisfied. This matters for maintainability. When a dependency changes — say `GranitPersistenceModule` drops its dependency on `GranitTimingModule` — the build breaks only in modules that actually use Timing directly but relied on the transitive path. The break is immediate, localized, and fixable by adding the missing `[DependsOn]`. Compare this to the alternative, where every module redundantly declares the full transitive closure: changes to deep dependencies require updating dozens of unrelated modules. One exception: `Granit.Core` is the implicit root. Every module depends on it. No `[DependsOn(typeof(GranitCoreModule))]` is needed or expected. ## The DAG [Section titled “The DAG”](#the-dag) Ninety-three packages organized into a strict **directed acyclic graph** with five layers. Each layer may depend on the layers below it, never on the layers above. ``` graph TB subgraph Business ["Business (38 packages)"] B1["Templating (8)"] B2["Notifications (15)"] B3["DataExchange (6)"] B4["Workflow (4)"] B5["Timeline (4)"] B6["BackgroundJobs (4)"] end subgraph Functional ["Functional (26 packages)"] F1["Localization (4)"] F2["Web / API (9)"] F3["Configuration (8)"] F4["Storage (5)"] end subgraph Infrastructure ["Infrastructure (6 packages)"] I1["Persistence (3)"] I2["Wolverine (3)"] end subgraph Foundation ["Foundation (22 packages)"] FO1["Security (12)"] FO2["Identity (5)"] FO3["Caching (3)"] FO4["Utilities (2)"] end subgraph Core ["Core (1 package)"] C["Granit.Core"] end Business --> Functional Business --> Infrastructure Functional --> Infrastructure Functional --> Foundation Infrastructure --> Foundation Foundation --> Core Infrastructure --> Core style C fill:#e8f5e9,stroke:#388e3c,color:#1b5e20 style FO1 fill:#e3f2fd,stroke:#1565c0,color:#0d47a1 style FO2 fill:#e3f2fd,stroke:#1565c0,color:#0d47a1 style FO3 fill:#e3f2fd,stroke:#1565c0,color:#0d47a1 style FO4 fill:#e3f2fd,stroke:#1565c0,color:#0d47a1 style I1 fill:#fff3e0,stroke:#ef6c00,color:#e65100 style I2 fill:#fff3e0,stroke:#ef6c00,color:#e65100 style F1 fill:#f3e5f5,stroke:#7b1fa2,color:#4a148c style F2 fill:#f3e5f5,stroke:#7b1fa2,color:#4a148c style F3 fill:#f3e5f5,stroke:#7b1fa2,color:#4a148c style F4 fill:#f3e5f5,stroke:#7b1fa2,color:#4a148c style B1 fill:#fce4ec,stroke:#c62828,color:#b71c1c style B2 fill:#fce4ec,stroke:#c62828,color:#b71c1c style B3 fill:#fce4ec,stroke:#c62828,color:#b71c1c style B4 fill:#fce4ec,stroke:#c62828,color:#b71c1c style B5 fill:#fce4ec,stroke:#c62828,color:#b71c1c style B6 fill:#fce4ec,stroke:#c62828,color:#b71c1c ``` The **maximum depth** of the graph is five levels. No package sits more than five edges away from `Granit.Core`. This is not a soft guideline — it is a consequence of the layering rules. A Business-layer package cannot reference another Business-layer package outside its own family (e.g., `Granit.Notifications` cannot reference `Granit.Workflow` directly). Cross-cutting concerns live in lower layers, available to everyone above. Within each family, packages follow a predictable internal structure. Take Notifications (15 packages) as an example: * **`Granit.Notifications`** — domain types, interfaces, fan-out logic * **`Granit.Notifications.EntityFrameworkCore`** — persistence (isolated DbContext) * **`Granit.Notifications.Endpoints`** — Minimal API routes * **`Granit.Notifications.Email`** — email channel abstraction * **`Granit.Notifications.Email.Smtp`** — SMTP transport * **`Granit.Notifications.Brevo`** — Brevo (Sendinblue) transport * **`Granit.Notifications.Sms`**, **`.WhatsApp`**, **`.Push`**, **`.SignalR`** — additional channels The rule is: **one project = one NuGet package, namespace = project name**. An application that only needs email notifications installs `Granit.Notifications.Email.Smtp` — not all 15 packages. The DAG ensures that pulling in SMTP brings exactly `Notifications`, `Notifications.Email`, and their lower-layer dependencies. Nothing more. ## Build-time enforcement [Section titled “Build-time enforcement”](#build-time-enforcement) The startup DAG validation catches cycles at runtime. But runtime is too late for a CI pipeline. Granit runs **architecture tests** in every build that enforce the same invariants statically. ### No circular project references [Section titled “No circular project references”](#no-circular-project-references) `ProjectDependencyTests` parses every `.csproj` in the `src/` directory, builds an adjacency graph from `` elements, and runs a DFS-based cycle detector. If project A references project B and project B references project A (directly or transitively), the test fails: ProjectDependencyTests.cs ```csharp [Fact] public void No_circular_project_references() { // Builds adjacency list from .csproj ProjectReference elements // DFS with coloring: white=unvisited, gray=in-stack, black=done // Any gray→gray edge is a cycle → test fails } ``` This test operates at the MSBuild level — it catches cycles that `[DependsOn]` would not, because not every project exposes a module class. Pure library packages (analyzers, source generators, abstractions) have no `GranitModule` but still participate in the dependency graph. ### Layer boundary enforcement [Section titled “Layer boundary enforcement”](#layer-boundary-enforcement) `LayerDependencyTests` uses ArchUnitNET to enforce that types in lower layers do not reference types in higher layers: * **Core types** must not depend on `EntityFrameworkCore` namespaces * **Endpoint types** must not depend on `EntityFrameworkCore` namespaces * **Endpoint types** must not inherit from domain entities * **`IQueryable`** must not escape the persistence layer (with explicit exceptions for DataExchange, which needs it by design) These tests encode the layering rules from the diagram above as executable specifications. A developer cannot accidentally add a `using Granit.Persistence` in a package that belongs to the Foundation layer — the build fails. ### Module naming conventions [Section titled “Module naming conventions”](#module-naming-conventions) `ModuleConventionTests` enforces that all `GranitModule` subclasses are **sealed** and follow the `Granit*Module` naming pattern. Sealing prevents inheritance hierarchies among modules, which would create implicit dependencies invisible to `[DependsOn]`. ## Soft dependencies [Section titled “Soft dependencies”](#soft-dependencies) Not every cross-cutting concern justifies a hard `[DependsOn]`. Multi-tenancy is the canonical example. Many Granit modules are **tenant-aware** — they filter queries by `TenantId`, scope caches by tenant, or tag audit entries with tenant context. But multi-tenancy is optional. A single-tenant application should not be forced to install `Granit.MultiTenancy`. Granit solves this with the **soft dependency** pattern. The abstraction — `ICurrentTenant` — lives in `Granit.Core.MultiTenancy`, available to every package without an additional reference: ICurrentTenant.cs (in Granit.Core) ```csharp public interface ICurrentTenant { bool IsAvailable { get; } Guid? Id { get; } string? Name { get; } IDisposable Change(Guid? id, string? name = null); } ``` At startup, `AddGranit()` registers a **null object implementation** via `TryAddSingleton`: GranitHostBuilderExtensions.cs ```csharp builder.Services.TryAddSingleton(NullTenantContext.Instance); ``` `NullTenantContext.IsAvailable` always returns `false`. `Change()` is a no-op. Any module can inject `ICurrentTenant` and check `IsAvailable` before using `Id`. If the application includes `Granit.MultiTenancy`, its module replaces the null object with the real `AsyncLocal`-based implementation. If not, everything continues to work — just without tenant isolation. This pattern keeps the dependency graph clean. Persistence, Caching, BlobStorage, and Notifications all read `ICurrentTenant` without declaring `[DependsOn(typeof(GranitMultiTenancyModule))]`. The same principle applies to `IDataFilter` (in `Granit.Core.DataFiltering`), which controls global query filters for soft-delete, active status, and publication state. The rule: a hard `[DependsOn]` is required only when the module **must enforce** the dependency’s presence — for example, `Granit.BlobStorage` requires a tenant context for GDPR-compliant file isolation and throws if none is available. ## Bundles [Section titled “Bundles”](#bundles) Ninety-three packages provide granularity. But most applications do not need to pick packages one by one. Granit ships **bundles** — meta-packages with zero code that aggregate common sets: | Bundle | Contents | | ----------------------------- | ----------------------------------------------------------------------------------------------------- | | `Granit.Bundle.Essentials` | Core, Timing, Guids, Security, Validation, Persistence, Observability, ExceptionHandling, Diagnostics | | `Granit.Bundle.Api` | Essentials + ApiVersioning, ApiDocumentation, Cors, Idempotency, Localization, Caching | | `Granit.Bundle.Documents` | Templating, DocumentGeneration (PDF + Excel) | | `Granit.Bundle.Notifications` | Notifications, Notifications.EF, Notifications.Endpoints, Email, Email.Smtp, SignalR | | `Granit.Bundle.SaaS` | MultiTenancy, Features, RateLimiting, Bulkhead | A bundle `.csproj` contains only `` elements — no source files, no module class. It is a curated dependency set that the application can install with a single package reference. The DAG remains the same; the bundle is just a convenient entry point. Critically, bundles compose. `Granit.Bundle.Api` references `Granit.Bundle.Essentials`. An application that installs `Bundle.Api` gets everything from `Essentials` transitively. The topological sort deduplicates — every module is loaded exactly once regardless of how many paths lead to it. ## Why this matters [Section titled “Why this matters”](#why-this-matters) The zero-cycle guarantee is not an academic exercise. It has practical consequences that compound over time. **Incremental builds are fast.** When a developer changes `Granit.Notifications.Email.Smtp`, only that project and its dependents rebuild. There are no hidden reverse edges that pull in unrelated packages. On a 100+-package solution, this is the difference between a 4-second incremental build and a 40-second full rebuild. **Teams can work in parallel.** Two teams working on Notifications and DataExchange cannot create a cycle between their packages. The layering rules make it structurally impossible. If both teams need shared functionality, it belongs in a lower layer — which forces the right architectural conversation. **Extraction is mechanical.** Because no package at the Business layer can reference a sibling family, extracting `Granit.Notifications` into a separate repository means copying the 15 Notifications packages and their declared dependencies. The dependency graph tells you exactly what comes along. No hidden coupling, no surprises at link time. **Build-time safety catches mistakes early.** Architecture tests run in CI on every push. A junior developer adding a convenient `ProjectReference` that breaks the layering gets immediate feedback — not a design review two weeks later. The rules are encoded as tests, not as wiki pages that no one reads. The combination of runtime DAG validation, build-time architecture tests, and a layered design that makes cycles unlikely creates a system where the dependency graph is both a design artifact and an enforced invariant. At 100+ packages, that enforcement is not optional — it is the only thing standing between a modular framework and an accidental monolith. ## Further reading [Section titled “Further reading”](#further-reading) * [Dependency Graph](/reference/dependency-graph/) — interactive visualization of the full package graph * [Module System pattern](/architecture/patterns/module-system/) — detailed pattern description with implementation guidance * [Layered Architecture pattern](/architecture/patterns/layered-architecture/) — the four-layer model and its rules # Concepts > Core concepts and design principles behind Granit This section explains the foundational concepts that inform how Granit is designed and how its modules interact. Understanding these concepts will help you make informed decisions about which modules to use and how to configure them for your application. ## Core architecture [Section titled “Core architecture”](#core-architecture) * [Module System](./module-system/) — `[DependsOn]`, topological sort, lifecycle hooks * [Dependency Injection](./dependency-injection/) — Granit DI conventions, Options pattern * [Configuration](./configuration/) — configuration sources, layering, typed options * [Bundles](./bundles/) — meta-packages and the fluent `GranitBuilder` API ## Data and infrastructure [Section titled “Data and infrastructure”](#data-and-infrastructure) * [Persistence](./persistence/) — isolated DbContext, interceptors, conventions * [Multi-Tenancy](./multi-tenancy/) — 3 isolation strategies, soft dependency * [Messaging](./messaging/) — Wolverine, Channel fallback, transactional outbox * [Wolverine Optionality](./wolverine-optionality/) — what works without Wolverine ## Security and compliance [Section titled “Security and compliance”](#security-and-compliance) * [Security Model](./security-model/) — authentication, authorization, encryption * [Compliance](./compliance/) — GDPR, ISO 27001 enforcement ## Architecture decisions [Section titled “Architecture decisions”](#architecture-decisions) * [Modular Monolith vs Microservices](./modular-monolith-vs-microservices/) — when to stay monolith, when to extract # Bundles > Meta-packages that group related modules — Essentials, Api, Documents, Notifications, SaaS — for quick onboarding ## The problem [Section titled “The problem”](#the-problem) Granit ships 135 packages. That level of granularity is great for control, but overwhelming when you just want to start a new project. You should not have to know the full module graph to get a working API up. **Bundles** solve this. A bundle is a meta-package that pulls in a curated set of related modules. One package reference, one `Add*()` call, and the entire group is wired. ## Available bundles [Section titled “Available bundles”](#available-bundles) | Bundle | Includes | Use case | | ----------------- | ------------------------------------------------------------------------------------ | --------------------------- | | **Essentials** | Core, Timing, Guids, Validation, ExceptionHandling, Observability | Every project | | **Api** | Essentials + ApiVersioning, ApiDocumentation, Cors, Idempotency, Cookies | REST API projects | | **Documents** | Templating.Scriban, DocumentGeneration.Pdf, DocumentGeneration.Excel | Document generation | | **Notifications** | Notifications + Email.Smtp, Sms, Push, SignalR | Multi-channel notifications | | **SaaS** | Api + MultiTenancy, Settings, Features, Localization, BackgroundJobs, Caching.Hybrid | Full SaaS platform | Each bundle is a NuGet meta-package with no code of its own. It only declares dependencies on the modules it groups. ## Usage [Section titled “Usage”](#usage) Reference the bundle package and call the corresponding method on the Granit builder: * Single bundle ```csharp builder.AddGranit(granit => granit .AddSaaS()); ``` * Multiple bundles ```csharp builder.AddGranit(granit => granit .AddApi() .AddNotifications() .AddDocuments()); ``` ## GranitBuilder fluent API [Section titled “GranitBuilder fluent API”](#granitbuilder-fluent-api) Bundles are built on the same `GranitBuilder` fluent API that individual modules use. If a bundle groups more than you need, skip it and pick modules directly: ```csharp builder.AddGranit(granit => granit .AddEssentials() .AddModule() .AddModule()); ``` This gives you full control without losing the ergonomics of the builder pattern. ## Bundles are additive [Section titled “Bundles are additive”](#bundles-are-additive) You can combine any number of bundles in a single call. Deduplication is automatic: the module dependency graph tracks what is already registered and skips duplicates. Calling `.AddSaaS()` (which includes Api, which includes Essentials) and then `.AddNotifications()` works without conflict. ```csharp // SaaS already includes Api and Essentials — no duplicates builder.AddGranit(granit => granit .AddSaaS() .AddNotifications() .AddDocuments()); ``` ## When NOT to use bundles [Section titled “When NOT to use bundles”](#when-not-to-use-bundles) Bundles are convenience, not requirement. If your project only needs two or three specific packages, reference them directly: ```xml ``` This keeps your dependency footprint minimal and avoids pulling in modules you will never configure. ## Further reading [Section titled “Further reading”](#further-reading) * [Module System](./module-system/) — how `DependsOn` and the module graph work * [Reference overview](../reference/) — per-module API documentation # Compliance > How Granit enforces GDPR and ISO 27001 through architectural constraints — not afterthoughts ## The problem [Section titled “The problem”](#the-problem) Compliance is usually bolted on after the fact. A security audit reveals gaps, someone adds logging in a few places, a consultant writes a policy document, and the team moves on until the next audit. Granit takes a different approach: compliance requirements are **architectural constraints** enforced by the framework itself. You cannot accidentally skip the audit trail because the interceptor runs on every write. You cannot forget tenant isolation because the query filter is applied globally. The framework makes the compliant path the default path. ## GDPR — Articles 17, 18, and 25 [Section titled “GDPR — Articles 17, 18, and 25”](#gdpr--articles-17-18-and-25) The General Data Protection Regulation requires data minimization, right to erasure, processing restriction, data portability, and privacy by design. Granit maps each requirement to a concrete framework mechanism. ### Data minimization [Section titled “Data minimization”](#data-minimization) Each module stores only what it needs. There is no central “user profile” table with 40 columns — identity data lives in the IdP, cached fields are explicitly declared in `UserCacheEntity`, and modules reference users by ID only. ### Right to erasure (Art. 17) [Section titled “Right to erasure (Art. 17)”](#right-to-erasure-art-17) Granit uses logical deletion, not physical deletion, through the `ISoftDeletable` interface and `SoftDeleteInterceptor`. When `Delete()` is called on a soft-deletable entity, the interceptor sets `IsDeleted = true` and `DeletedAt` / `DeletedBy` fields. The record stays in the database. ```csharp public interface ISoftDeletable { bool IsDeleted { get; set; } DateTimeOffset? DeletedAt { get; set; } string? DeletedBy { get; set; } } ``` A global query filter on `ISoftDeletable` excludes deleted records from all queries by default. When administrative access to deleted records is needed (audit, legal hold), the filter can be temporarily disabled: ```csharp public async Task> GetDeletedPatientsAsync( IDataFilter dataFilter, AppDbContext db, CancellationToken cancellationToken) { using (dataFilter.Disable()) { return await db.Patients .Where(p => p.IsDeleted) .Select(p => new PatientResponse(p.Id, p.FullName, p.DeletedAt)) .ToListAsync(cancellationToken) .ConfigureAwait(false); } } ``` Warning Soft-deleted records are preserved for audit purposes (3 years minimum under ISO 27001). Physical deletion requires explicit administrative action and is logged in the Timeline module. ### Processing restriction (Art. 18) [Section titled “Processing restriction (Art. 18)”](#processing-restriction-art-18) The `IProcessingRestrictable` interface marks entities whose processing can be suspended on data subject request. Like `ISoftDeletable`, it has a corresponding global query filter. Restricted records are invisible to normal queries but remain in the database for legal compliance. ```csharp public interface IProcessingRestrictable { bool IsProcessingRestricted { get; set; } DateTimeOffset? ProcessingRestrictedAt { get; set; } string? ProcessingRestrictedBy { get; set; } } ``` ### Data portability [Section titled “Data portability”](#data-portability) `Granit.Privacy` provides the `IPersonalDataProvider` interface. Each module that stores personal data registers a provider that can export its data in a structured format. ```csharp public class PatientPersonalDataProvider : IPersonalDataProvider { public string Category => "Medical records"; public async Task ExportAsync( string userId, CancellationToken cancellationToken) { // Query all personal data for this user // Return structured export (JSON-serializable) } } ``` The privacy module aggregates all registered providers to produce a complete data export for a given user — satisfying the right to data portability without each module knowing about the others. ### Pseudonymization [Section titled “Pseudonymization”](#pseudonymization) Two encryption strategies are available, both accessible through `IStringEncryptionService`: * **Granit.Encryption** — AES-256-CBC with a local key. Suitable for development and non-production environments. * **Granit.Vault** — Abstraction layer (`ITransitEncryptionService`, `IDatabaseCredentialProvider`). Provider packages supply the implementations. * **Granit.Vault.HashiCorp** — HashiCorp Vault Transit engine. The encryption key never leaves Vault. Required for production under ISO 27001. * **Granit.Vault.Azure** — Azure Key Vault RSA-OAEP-256 encryption. The encryption key never leaves Azure Key Vault. Alternative production backend for Azure-hosted environments. ```csharp public class PatientService(IStringEncryptionService encryption) { public async Task StoreNationalIdAsync( string nationalId, CancellationToken cancellationToken) { string encrypted = await encryption .EncryptAsync(nationalId, cancellationToken) .ConfigureAwait(false); // Store encrypted value -- plaintext never persisted return encrypted; } } ``` ## ISO 27001 — Information security [Section titled “ISO 27001 — Information security”](#iso-27001--information-security) ### Audit trail [Section titled “Audit trail”](#audit-trail) `AuditedEntityInterceptor` is registered globally through `ApplyGranitConventions`. Every entity that inherits from `AuditedEntity` gets four fields filled automatically on every write: | Field | Set on | Source | | ------------ | --------------- | -------------------------------------------- | | `CreatedAt` | Insert | `IClock.Now` (always UTC) | | `CreatedBy` | Insert | `ICurrentUserService.UserId` (or `"system"`) | | `ModifiedAt` | Insert + Update | `IClock.Now` | | `ModifiedBy` | Insert + Update | `ICurrentUserService.UserId` (or `"system"`) | Application code never sets these fields. The interceptor runs inside the `SaveChangesAsync` pipeline, so it participates in the same transaction as the business write. Retention: 3 years minimum. ### Encryption at rest [Section titled “Encryption at rest”](#encryption-at-rest) `IStringEncryptionService` provides a unified API for encrypting sensitive fields before they reach the database. Two implementations: | Implementation | Key management | Use case | | --------------------------------------- | ------------------------------ | ------------------------------------------------ | | `AesStringEncryptionService` | Local config key (AES-256-CBC) | Development, testing | | `VaultTransitEncryptionService` | HashiCorp Vault Transit | Production (key never leaves Vault) | | `AzureKeyVaultStringEncryptionProvider` | Azure Key Vault RSA-OAEP-256 | Production on Azure (key never leaves Key Vault) | The Vault module is conditionally loaded — it disables itself in `Development` environment, falling back to the local AES implementation automatically. ### Encryption in transit [Section titled “Encryption in transit”](#encryption-in-transit) Granit enforces HTTPS-only. The `GranitJwtBearerModule` sets `RequireHttpsMetadata = true` by default. There is no HTTP fallback configuration option in production — the option validator rejects it outside `Development`. ### Tenant isolation [Section titled “Tenant isolation”](#tenant-isolation) Multi-tenancy provides three isolation levels, each mapping to a different ISO 27001 control: | Strategy | Isolation | Data residency | Use case | | ----------------- | ------------------------------------ | ----------------------------- | ------------------------------------------- | | SharedDatabase | Logical (query filter on `TenantId`) | Same database | Cost-effective, most deployments | | SchemaPerTenant | Schema-level | Same server, separate schemas | Stronger isolation | | DatabasePerTenant | Physical | Separate databases | Certification requirement, data sovereignty | The `IMultiTenant` query filter is applied by `ApplyGranitConventions` — application code never writes `WHERE TenantId = ...`. ### Timeline — entity-level activity log [Section titled “Timeline — entity-level activity log”](#timeline--entity-level-activity-log) The Timeline module records entity-level events with author, timestamp, and a Markdown body. Unlike the audit trail (which captures field-level changes), the Timeline captures **business events**: “Invoice approved by Marie”, “Patient file transferred to Dr. Dupont”. ```csharp public async Task ApproveInvoiceAsync( Guid invoiceId, ITimelineWriter timelineWriter, CancellationToken cancellationToken) { // ... business logic ... await timelineWriter.WriteAsync(new TimelineEntry { EntityType = "Invoice", EntityId = invoiceId.ToString(), Action = "Approved", Body = "Invoice approved for payment." }, cancellationToken).ConfigureAwait(false); } ``` ## UTC enforcement [Section titled “UTC enforcement”](#utc-enforcement) Granit provides `IClock` (and integrates with .NET’s `TimeProvider`) as the sole source of time. `IClock.Now` always returns UTC. The framework never calls `DateTime.Now` or `DateTime.UtcNow` directly. ## Compliance matrix [Section titled “Compliance matrix”](#compliance-matrix) Which Granit modules enforce which compliance requirements: | Module | GDPR Art. 17 | GDPR Art. 18 | GDPR Art. 25 | ISO 27001 Audit | ISO 27001 Encryption | | ---------------------- | :----------: | :--------------------: | :---------------: | :---------------: | :------------------: | | Granit.Persistence | Soft delete | Processing restriction | — | Audit interceptor | — | | Granit.Privacy | Data export | — | Privacy by design | — | — | | Granit.Encryption | — | — | Pseudonymization | — | AES-256-CBC | | Granit.Vault | — | — | Pseudonymization | — | Abstraction layer | | Granit.Vault.HashiCorp | — | — | Pseudonymization | — | Transit engine | | Granit.Vault.Azure | — | — | Pseudonymization | — | Azure Key Vault RSA | | Granit.MultiTenancy | — | — | Data isolation | Tenant isolation | — | | Granit.Security | — | — | — | Actor attribution | — | | Granit.Authorization | — | — | — | Permission audit | — | | Granit.Observability | — | — | — | Structured logs | — | | Granit.Timeline | — | — | — | Activity log | — | | Granit.Workflow | — | — | — | Transition log | — | | Granit.Templating | — | — | — | — | — | | Granit.Timing | — | — | — | UTC enforcement | — | ## Design decisions [Section titled “Design decisions”](#design-decisions) **Why soft delete instead of physical delete?** Audit retention. ISO 27001 requires a 3-year audit trail. If you physically delete a record, the audit entries referencing it become orphaned. Soft delete preserves referential integrity while hiding the record from normal queries. Physical deletion is available as an explicit administrative action for end-of-retention cleanup. **Why two encryption implementations?** Development velocity. Developers should not need a running Vault instance to work locally. The `IsEnabled` check on `GranitVaultModule` automatically falls back to AES in development. In production, the Vault Transit engine provides key rotation, access logging, and HSM backing — none of which a local key can offer. **Why global query filters instead of repository-level checks?** A missed `WHERE` clause is a data breach. Global filters on `ISoftDeletable`, `IMultiTenant`, `IProcessingRestrictable`, `IActive`, and `IPublishable` are applied by `ApplyGranitConventions` in `OnModelCreating`. You cannot forget them because you never write them. ## Next steps [Section titled “Next steps”](#next-steps) * [Privacy reference](/reference/modules/privacy/) — full API surface of `Granit.Privacy` * [Persistence reference](/reference/modules/persistence/) — `AuditedEntity`, interceptors, query filters * [Security model concept](./security-model/) — authentication and authorization architecture * [Multi-tenancy concept](./multi-tenancy/) — tenant resolution and isolation strategies # Configuration > Three configuration mechanisms — Options (startup), Settings (runtime), and Module Config (frontend) — and when to use each ## The problem [Section titled “The problem”](#the-problem) ”.NET has `IOptions`, why do I need anything else?” Because `IOptions` is bound at startup from static files and environment variables. It cannot change at runtime without a redeploy. But real applications need user-specific preferences, tenant-level overrides, and admin-togglable knobs — all modifiable through an API or UI while the application is running. Granit provides three distinct configuration mechanisms. Each has a different purpose, source, and audience. Using the wrong one leads to either unnecessary redeployments or runtime fragility. ## The three mechanisms [Section titled “The three mechanisms”](#the-three-mechanisms) | Mechanism | When | Source | Modifiable at runtime | Typing | Scope | Consumer | | ------------------------------------------ | ------- | ----------------------------------- | --------------------- | ------------------ | ----------------------------------------- | ------------------ | | Options (`IOptions`) | Startup | `appsettings.json`, env vars, Vault | No (redeploy) | Strong (C# class) | Application | Backend | | Settings (`ISettingProvider`) | Runtime | Database | Yes (API/UI) | Weak (string) | User > Tenant > Global > Config > Default | Backend + Frontend | | Module Config (`IModuleConfigProvider`) | Runtime | Depends on impl | Read-only | Strong (C# record) | Application / module | Frontend mainly | *** ## Options (`IOptions`) [Section titled “Options (IOptions\)”](#options-ioptionst) Options are the standard .NET mechanism for strongly-typed startup configuration. Granit enforces a strict pattern for all module options. See [Dependency Injection](./dependency-injection/) for the full recipe. ### Configuration sources hierarchy [Section titled “Configuration sources hierarchy”](#configuration-sources-hierarchy) ASP.NET Core loads configuration in this order. Later sources override earlier ones: ```plaintext appsettings.json -> appsettings.{Environment}.json -> Environment variables -> CLI arguments -> User Secrets (Development only) -> Vault (via Granit.Vault dynamic credentials) ``` ### Defining options [Section titled “Defining options”](#defining-options) ```csharp public sealed class VaultOptions { public const string SectionName = "Vault"; [Required] public string Address { get; set; } = string.Empty; [Required] public string RoleId { get; set; } = string.Empty; [Required] public string SecretId { get; set; } = string.Empty; public TimeSpan TokenRenewalInterval { get; set; } = TimeSpan.FromMinutes(15); } ``` ### Registering options [Section titled “Registering options”](#registering-options) ```csharp services .AddOptions() .BindConfiguration(VaultOptions.SectionName) .ValidateDataAnnotations() .ValidateOnStart(); ``` ### When to use Options [Section titled “When to use Options”](#when-to-use-options) * Connection strings, endpoint URLs, timeouts * Feature flags that require a redeploy to change (infrastructure-level) * Secrets injected via environment variables or Vault * Anything consumed before the DI container is built (Serilog, OpenTelemetry) Warning Never store secrets in `appsettings.json`. Use environment variables, User Secrets (development), or Vault (production). The Options pattern binds from all configuration sources transparently — the consuming code does not know where the value came from. *** ## Settings (`ISettingProvider`) [Section titled “Settings (ISettingProvider)”](#settings-isettingprovider) Settings are runtime key-value pairs stored in the database. They support a cascading resolution model: a value set at the user level overrides the same key at the tenant level, which overrides the global level, and so on. ### Value cascade [Section titled “Value cascade”](#value-cascade) Settings resolve through a provider chain. The first provider that returns a non-null value wins: | Priority | Provider | Scope | Description | | -------- | ------------- | ------------------ | ----------------------------------------------- | | 100 | User | Per-user | Personal preferences (locale, theme, page size) | | 200 | Tenant | Per-tenant | Tenant-wide defaults | | 300 | Global | Application-wide | Admin-defined defaults | | 400 | Configuration | `appsettings.json` | Deployment-time defaults | | 500 | Default | Code | Hardcoded fallback in `SettingDefinition` | ``` graph LR U["User (100)"] -->|null?| T["Tenant (200)"] T -->|null?| G["Global (300)"] G -->|null?| C["Configuration (400)"] C -->|null?| D["Default (500)"] ``` When `IsInherited = false` on a `SettingDefinition`, each scope is independent and the cascade does not fall through. ### Defining a setting [Section titled “Defining a setting”](#defining-a-setting) Implement `ISettingDefinitionProvider` in any module. Providers are auto-discovered at startup. ```csharp public sealed class AcmeSettingDefinitionProvider : ISettingDefinitionProvider { public void Define(ISettingDefinitionContext context) { context.Add(new SettingDefinition("Acme.DefaultPageSize") { DefaultValue = "25", IsVisibleToClients = true, IsInherited = true, Description = "Default number of items per page" }); context.Add(new SettingDefinition("Acme.SmtpPassword") { DefaultValue = null, IsEncrypted = true, IsVisibleToClients = false, Providers = { "G" } // Global scope only }); } } ``` ### Reading a setting (with cascade) [Section titled “Reading a setting (with cascade)”](#reading-a-setting-with-cascade) ```csharp public class ReportService(ISettingProvider settings) { public async Task GetPageSizeAsync(CancellationToken ct) { // Resolves User -> Tenant -> Global -> Config -> Default string? value = await settings .GetOrNullAsync("Acme.DefaultPageSize", ct) .ConfigureAwait(false); return int.TryParse(value, out int size) ? size : 25; } } ``` ### Writing a setting [Section titled “Writing a setting”](#writing-a-setting) ```csharp public class AdminService(ISettingManager settingManager) { public async Task SetGlobalPageSizeAsync( int size, CancellationToken ct) { await settingManager .SetGlobalAsync("Acme.DefaultPageSize", size.ToString(), ct) .ConfigureAwait(false); } public async Task SetUserLocaleAsync( string userId, string locale, CancellationToken ct) { await settingManager .SetForUserAsync(userId, "Granit.PreferredCulture", locale, ct) .ConfigureAwait(false); } } ``` ### Encrypted settings [Section titled “Encrypted settings”](#encrypted-settings) Settings marked `IsEncrypted = true` are encrypted at rest in the database using `IStringEncryptionService`. The cache holds plaintext values — when Redis is used, the cache transport is encrypted separately via TLS and Granit’s cache encryption layer. This means: * Database backups do not contain plaintext secrets. * The application reads plaintext from cache without decryption overhead on every access. * Cache invalidation triggers re-read and re-decrypt from the database. ### When to use Settings [Section titled “When to use Settings”](#when-to-use-settings) * User preferences (locale, timezone, theme, page size) * Tenant-level configuration (SMTP server, billing thresholds) * Admin-togglable knobs that must change without a redeploy * Any key-value pair that needs per-user or per-tenant scoping *** ## Module Config (`IModuleConfigProvider`) [Section titled “Module Config (IModuleConfigProvider\)”](#module-config-imoduleconfigprovidert) Module Config is a lightweight read-only mechanism for exposing module state to the frontend. Unlike Options (which are internal to the backend) and Settings (which are read-write), Module Config provides a typed, public-facing snapshot of a module’s current configuration. ### Defining a module config provider [Section titled “Defining a module config provider”](#defining-a-module-config-provider) ```csharp public sealed record WebhookModuleConfigResponse(bool StorePayload); internal sealed class WebhookModuleConfigProvider( IOptions options) : IModuleConfigProvider { public WebhookModuleConfigResponse GetConfig() => new(options.Value.StorePayload); } ``` ### Exposing via HTTP [Section titled “Exposing via HTTP”](#exposing-via-http) Program.cs ```csharp app.MapGranitModuleConfig(); // GET /webhooks/config -> { "storePayload": true } ``` ### When to use Module Config [Section titled “When to use Module Config”](#when-to-use-module-config) * Frontend needs to know if a module feature is available * Frontend conditionally renders UI based on backend configuration * Read-only — if the frontend needs to change the value, use Settings instead *** ## Decision diagram [Section titled “Decision diagram”](#decision-diagram) Use this diagram to pick the right mechanism for a new configuration value: ``` flowchart TD A[New configuration value] --> B{Needed at startup?} B -->|Yes| C{Secret?} C -->|Yes| D["Options + Vault / env var"] C -->|No| E["Options (appsettings.json)"] B -->|No| F{Modifiable at runtime?} F -->|Yes| G{Per-user or per-tenant?} G -->|Yes| H["Settings (ISettingProvider)"] G -->|No| I{Secret?} I -->|Yes| J["Settings (IsEncrypted = true)"] I -->|No| H F -->|No| K{Consumed by frontend?} K -->|Yes| L["Module Config (IModuleConfigProvider)"] K -->|No| E ``` *** ## Comparison at a glance [Section titled “Comparison at a glance”](#comparison-at-a-glance) | Question | Options | Settings | Module Config | | ---------------------------- | --------------------- | ------------------- | ------------------------ | | Can a user change it? | No | Yes | No | | Can a tenant override it? | No | Yes | No | | Requires redeploy to change? | Yes | No | Depends on source | | Strongly typed? | Yes (`sealed class`) | No (string values) | Yes (`sealed record`) | | Encrypted at rest? | Via Vault / env vars | `IsEncrypted` flag | N/A | | Cached? | In memory (singleton) | Distributed cache | In memory (singleton) | | Exposed via HTTP? | No | Yes (`/settings/*`) | Yes (`/{module}/config`) | | Typical consumer | Backend services | Backend + frontend | Frontend | *** ## See also [Section titled “See also”](#see-also) * [Dependency Injection](./dependency-injection/) — Options pattern details, `ValidateOnStart`, `PostConfigure` * [Settings, Features & Reference Data](../reference/modules/settings-features/) — full API reference for `ISettingProvider`, `ISettingManager`, and endpoints * [Vault & Encryption](../reference/modules/vault-encryption/) — secret management for Options-level configuration * [Caching](../reference/modules/caching/) — cache layer used by Settings for value resolution # Dependency Injection > Granit DI conventions — how modules register services, the Options pattern, and why there are no separate Abstractions packages ## The problem [Section titled “The problem”](#the-problem) Vanilla ASP.NET Core DI works fine for small applications. Once you cross twenty or thirty services, `Program.cs` becomes a dumping ground: authentication, caching, persistence, background jobs, observability — all crammed into a single file with no clear ownership. Modular frameworks need conventions that answer two questions: 1. **Who registers what?** Each module should own its own services. 2. **Where does configuration come from?** Parameters should not be scattered across method arguments, environment variables, and magic strings. Granit answers both with a module system and a strict Options pattern. ## Module service registration [Section titled “Module service registration”](#module-service-registration) Every Granit package exposes a `GranitModule` subclass. The module’s `ConfigureServices` method is the single entry point for DI registration — no extension methods in `Program.cs`, no global `Startup` class. ```csharp public sealed class GranitBulkheadModule : GranitModule { public override void ConfigureServices(ServiceConfigurationContext context) => context.Services.AddGranitBulkhead(); } ``` The `AddGranit*()` extension method is the internal implementation detail. Application code never calls it directly — the module system calls `ConfigureServices` for every module in the dependency graph, in topological order. ### Declaring module dependencies [Section titled “Declaring module dependencies”](#declaring-module-dependencies) Modules declare their dependencies with `[DependsOn]`. The module system resolves the graph at startup and calls `ConfigureServices` in the correct order: ```csharp [DependsOn(typeof(GranitPersistenceModule))] [DependsOn(typeof(GranitCachingModule))] public sealed class GranitSettingsEntityFrameworkCoreModule : GranitModule { public override void ConfigureServices(ServiceConfigurationContext context) => context.Services.AddGranitSettingsEntityFrameworkCore(); } ``` ### Service lifetime conventions [Section titled “Service lifetime conventions”](#service-lifetime-conventions) | Lifetime | When to use | Example | | --------- | ----------------------------------------------------------- | -------------------------------------------------------- | | Singleton | Stateless registries, metrics, configuration | `ConcurrencyLimiterRegistry`, `SettingDefinitionManager` | | Scoped | Anything that depends on the current request (user, tenant) | `ISettingProvider`, `ICurrentUserService` | | Transient | Rarely — only for lightweight, stateless, disposable types | `IClaimsTransformation` | Use `TryAdd*` to allow downstream modules to replace default implementations: ```csharp // Default in-memory store — replaced by EF Core store when that module is installed services.TryAddSingleton(); services.TryAddSingleton( sp => sp.GetRequiredService()); services.TryAddSingleton( sp => sp.GetRequiredService()); ``` ## The Options pattern [Section titled “The Options pattern”](#the-options-pattern) Every configurable Granit module follows the same recipe. No exceptions. ### The recipe [Section titled “The recipe”](#the-recipe) 1. Declare a `sealed class` with a `const string SectionName`. 2. Bind to the configuration section with `BindConfiguration`. 3. Add `ValidateDataAnnotations()` for constraint validation. 4. Add `ValidateOnStart()` so the application fails fast on misconfiguration. ```csharp public sealed class ObservabilityOptions { public const string SectionName = "Observability"; [Required] public string ServiceName { get; set; } = "unknown-service"; public string ServiceVersion { get; set; } = "0.0.0"; [Required] public string OtlpEndpoint { get; set; } = "http://localhost:4317"; public bool EnableTracing { get; set; } = true; public bool EnableMetrics { get; set; } = true; } ``` Registration in the module’s extension method: ```csharp builder.Services .AddOptions() .BindConfiguration(ObservabilityOptions.SectionName) .ValidateDataAnnotations() .ValidateOnStart(); ``` The corresponding `appsettings.json` section: ```json { "Observability": { "ServiceName": "my-backend", "OtlpEndpoint": "http://otel-collector:4317" } } ``` ### Custom validation [Section titled “Custom validation”](#custom-validation) When `DataAnnotations` are not enough, implement `IValidateOptions`: ```csharp internal sealed class BulkheadOptionsValidator : IValidateOptions { public ValidateOptionsResult Validate( string? name, GranitBulkheadOptions options) { if (options.MaxConcurrentRequests < 1) return ValidateOptionsResult.Fail( "MaxConcurrentRequests must be at least 1."); return ValidateOptionsResult.Success; } } ``` Register it as a singleton in the module’s extension method: ```csharp services.AddSingleton, BulkheadOptionsValidator>(); ``` ## The PostConfigure pattern [Section titled “The PostConfigure pattern”](#the-postconfigure-pattern) Some modules need to override options registered by a dependency — without the consumer knowing about it. Granit uses `PostConfigure` for this. The canonical example: `Granit.Authentication.Keycloak` post-configures the standard `JwtBearerOptions` registered by `Granit.Authentication.JwtBearer`: ```csharp services .AddOptions(JwtBearerDefaults.AuthenticationScheme) .PostConfigure>((jwt, keycloakOpts) => { KeycloakOptions options = keycloakOpts.Value; string audience = options.Audience ?? options.ClientId; jwt.Authority = options.Authority; jwt.Audience = audience; jwt.RequireHttpsMetadata = options.RequireHttpsMetadata; jwt.TokenValidationParameters.NameClaimType = "preferred_username"; }); ``` The application only declares `[DependsOn(typeof(GranitAuthenticationKeycloakModule))]`. The Keycloak module’s `PostConfigure` runs after the JWT Bearer module’s `Configure`, transparently overriding Authority, Audience, and name claim type with Keycloak-specific values. ## No separate Abstractions packages [Section titled “No separate Abstractions packages”](#no-separate-abstractions-packages) Many .NET frameworks split every package into `Foo` and `Foo.Abstractions`. The idea is that consumers depend only on the interfaces, not the implementations. Granit does not do this. Interfaces and implementations live in the same package. With 135 packages, splitting would mean 186 packages — doubling the dependency graph complexity, NuGet restore time, and cognitive overhead, for minimal benefit. The trade-off works because: * **Module boundaries are the abstraction.** Each module exposes a small public API surface. Internal types are `internal`. * **`TryAdd*` handles replacement.** If a downstream module provides a different implementation, it wins — no separate abstractions package needed. * **Granit is a framework, not a library.** Consumers install whole modules, not individual interfaces. The module system manages the dependency graph. Warning If you find yourself wanting to reference only the interfaces from a Granit package, you are likely fighting the module system. Depend on the module instead — let `[DependsOn]` handle transitive dependencies. ## Summary [Section titled “Summary”](#summary) | Convention | Rule | | --------------------------- | ---------------------------------------------------------------------------------------------------- | | Service registration | `ConfigureServices` in the module class, delegates to `AddGranit*()` | | Options | `sealed class` + `SectionName` + `BindConfiguration` + `ValidateDataAnnotations` + `ValidateOnStart` | | Cross-module overrides | `PostConfigure` — never mutate another module’s options in `Configure` | | Abstractions | Same package as implementation — no `*.Abstractions` split | | Extension method parameters | None (configuration comes from `appsettings.json`) | # Messaging > Domain events, integration events, transactional outbox, and automatic context propagation across async boundaries ## The problem [Section titled “The problem”](#the-problem) In a modular monolith, modules need to communicate without coupling. A patient discharge in module A should release a bed in module B — but module A must not reference module B. The obvious answer is events. The less obvious problem: background processing needs the **same tenant, user, and trace context** as the originating HTTP request, or your audit trails break, your multi-tenant queries return wrong data, and your distributed traces have gaps. Granit solves both problems through two event types, a transactional outbox, and automatic context propagation. ## Two event types [Section titled “Two event types”](#two-event-types) ### IDomainEvent — local, in-process [Section titled “IDomainEvent — local, in-process”](#idomainevent--local-in-process) Domain events stay within the same process. They are routed to a local queue named `"domain-events"` and never leave the application boundary. Use them for side effects that belong to the same bounded context. ```csharp public sealed record PatientDischargedOccurred( Guid PatientId, Guid BedId) : IDomainEvent; ``` **Characteristics:** * Same process, same transaction boundary * Routed to the local `"domain-events"` queue * Never serialized to an external transport * Retried according to the configured retry policy ### IIntegrationEvent — durable, cross-module [Section titled “IIntegrationEvent — durable, cross-module”](#iintegrationevent--durable-cross-module) Integration events cross module boundaries. They are persisted in the transactional outbox and survive process crashes. Use them when the consumer is a different module or when at-least-once delivery matters. ```csharp public sealed record BedReleasedEvent( Guid BedId, Guid WardId, DateTimeOffset ReleasedAt) : IIntegrationEvent; ``` **Characteristics:** * Persisted in the outbox alongside business data * Delivered at-least-once (survives crashes, restarts) * Serialized as flat DTOs — never include EF Core entities or internal domain objects * Transported via PostgreSQL-based transport Integration events must be flat DTOs Never put EF Core entities, navigation properties, or internal domain objects into integration events. These events cross module boundaries and are serialized to the outbox. Include only primitive types, value objects, and identifiers. ## Transactional outbox [Section titled “Transactional outbox”](#transactional-outbox) The outbox pattern eliminates dual-write problems. Events are persisted in the **same database transaction** as business data. If the transaction commits, events will be delivered. If it rolls back, events are discarded. No orphaned messages, no lost updates. ``` sequenceDiagram participant H as Handler participant DB as PostgreSQL participant O as Outbox Table participant T as Transport H->>DB: UPDATE patients SET status = 'discharged' H->>O: INSERT outbox message (same TX) H->>DB: COMMIT Note over DB,O: Both succeed or both rollback O->>T: Dispatcher reads committed messages T-->>O: ACK -- DELETE from outbox ``` The handler does not need to manage any of this explicitly. Returning an event from a handler is enough: ```csharp public static class DischargePatientHandler { public static IEnumerable Handle( DischargePatientCommand command, PatientDbContext db) { var patient = db.Patients.Find(command.PatientId) ?? throw new EntityNotFoundException(typeof(Patient), command.PatientId); patient.Discharge(); // Domain event -- same transaction, local queue yield return new PatientDischargedOccurred(patient.Id, patient.BedId); // Integration event -- persisted in outbox, durable delivery yield return new BedReleasedEvent( patient.BedId, patient.WardId, DateTimeOffset.UtcNow); } } ``` ## Automatic context propagation [Section titled “Automatic context propagation”](#automatic-context-propagation) Three contexts propagate automatically through message envelopes, so background handlers behave exactly like the original HTTP request. ### Outgoing: HTTP request to envelope [Section titled “Outgoing: HTTP request to envelope”](#outgoing-http-request-to-envelope) `OutgoingContextMiddleware` reads the current tenant, user, and trace context from the ambient scope and writes them as headers on the outgoing message envelope: | Header | Source | Purpose | | ------------------ | ------------------------------- | ----------------------------------------- | | `X-Tenant-Id` | `ICurrentTenant.Id` | Tenant isolation in background handlers | | `X-User-Id` | `ICurrentUserService.UserId` | Audit trail (`CreatedBy`, `ModifiedBy`) | | `X-User-FirstName` | `ICurrentUserService.FirstName` | Audit display name | | `X-User-LastName` | `ICurrentUserService.LastName` | Audit display name | | `X-Actor-Kind` | `ICurrentUserService.ActorKind` | `User`, `ExternalSystem`, or `System` | | `X-Api-Key-Id` | `ICurrentUserService.ApiKeyId` | Service account identification | | `traceparent` | `Activity.Current?.Id` | W3C Trace Context for distributed tracing | Headers are omitted when the corresponding context is absent. No PII is logged. ### Incoming: envelope to handler context [Section titled “Incoming: envelope to handler context”](#incoming-envelope-to-handler-context) Three behaviors restore the context before the handler executes: | Behavior | Restores | Mechanism | | ----------------------- | ----------------------- | ----------------------------------------------------------------- | | `TenantContextBehavior` | `ICurrentTenant` | Sets `AsyncLocal` tenant scope | | `UserContextBehavior` | `ICurrentUserService` | Sets `AsyncLocal` user override via `WolverineCurrentUserService` | | `TraceContextBehavior` | `Activity` (trace/span) | Creates a new `Activity` linked to the parent `traceparent` | ### The full flow [Section titled “The full flow”](#the-full-flow) ``` sequenceDiagram participant HTTP as HTTP Request participant Mid as OutgoingContextMiddleware participant Env as Message Envelope participant Beh as Incoming Behaviors participant Handler as Async Handler HTTP->>Mid: TenantId, UserId, TraceId from ambient context Mid->>Env: X-Tenant-Id, X-User-Id, traceparent headers Note over Env: Persisted in outbox (same TX as business data) Env->>Beh: Read headers on dispatch Beh->>Handler: ICurrentTenant, ICurrentUserService, Activity restored Note over Handler: AuditedEntityInterceptor sees correct CreatedBy/ModifiedBy ``` The result: Grafana/Tempo shows a continuous trace from the original HTTP request through every async handler it triggered. Audit fields are populated correctly. Multi-tenant query filters apply the right tenant. ## WolverineCurrentUserService [Section titled “WolverineCurrentUserService”](#wolverinecurrentuserservice) In an HTTP request, `ICurrentUserService` reads claims from `HttpContext.User`. In a background handler, there is no `HttpContext`. `WolverineCurrentUserService` bridges this gap with a two-level resolution strategy: 1. **AsyncLocal override** — set by `UserContextBehavior` from envelope headers. Used in background handlers. 2. **HttpContext fallback** — reads claims from `IHttpContextAccessor`. Used in normal HTTP requests. This is registered automatically by `AddGranitWolverine()`. You never interact with it directly — `ICurrentUserService` just works in both contexts. ## Atomic rescheduling [Section titled “Atomic rescheduling”](#atomic-rescheduling) `RecurringJobSchedulingMiddleware` inserts the next cron schedule in the **same outbox transaction** as the current job’s completion. If the handler fails and the transaction rolls back, the reschedule is also rolled back — preventing duplicate schedules or missed runs. ## Configuration [Section titled “Configuration”](#configuration) ```json { "Wolverine": { "MaxRetryAttempts": 3, "RetryDelays": ["00:00:05", "00:00:30", "00:05:00"] }, "WolverinePostgresql": { "TransportConnectionString": "Host=db;Database=myapp;Username=app;Password=..." } } ``` | Property | Default | Description | | --------------------------- | ----------------- | ---------------------------------------------- | | `MaxRetryAttempts` | `3` | Maximum delivery attempts before dead-letter | | `RetryDelays` | `[5s, 30s, 5min]` | Delay between retries (exponential backoff) | | `TransportConnectionString` | — | PostgreSQL connection for the outbox transport | ## Setup [Section titled “Setup”](#setup) * With PostgreSQL outbox ```csharp [DependsOn(typeof(GranitWolverinePostgresqlModule))] public class AppModule : GranitModule { } ``` Production setup. Events are persisted in the outbox and delivered durably. * In-memory only ```csharp [DependsOn(typeof(GranitWolverineModule))] public class AppModule : GranitModule { } ``` Development setup. Events are in-memory only — lost on crash. ## See also [Section titled “See also”](#see-also) * [Wolverine Optionality](./wolverine-optionality/) — what works without Wolverine and when you actually need a message bus * [Wolverine reference](/reference/modules/wolverine/) — full API surface, setup variants, handler conventions * [BackgroundJobs reference](/reference/modules/background-jobs/) — recurring job scheduling with cron expressions # Modular Monolith vs Microservices > The architecture spectrum, where Granit fits, and a concrete migration path from monolith to microservices Architecture is not a binary choice between monolith and microservices. It is a spectrum, and the right position depends on your team size, operational maturity, and scaling requirements. Granit is designed to let you start at the sweet spot and move along the spectrum when the trade-offs justify it. ## The spectrum [Section titled “The spectrum”](#the-spectrum) ``` graph LR A["Monolith
Low complexity
Low ops cost"] --> B["Modular Monolith
Moderate complexity
Low ops cost"] B --> C["Microservices
High complexity
High ops cost"] style A fill:#e8f5e9,stroke:#388e3c,color:#1b5e20 style B fill:#fff3e0,stroke:#f57c00,color:#e65100 style C fill:#ffebee,stroke:#c62828,color:#b71c1c ``` | | Monolith | Modular Monolith | Microservices | | ----------------- | ------------------- | -------------------------------- | ------------------------- | | Deployment | Single unit | Single unit | Independent per service | | Module boundaries | None or conventions | Enforced in code | Enforced by network | | Data isolation | Shared tables | Isolated DbContext per module | Separate databases | | Communication | Direct method calls | In-process channels or messaging | Network (HTTP, messaging) | | Operational cost | Low | Low | High | | Team scalability | Limited | Good | High | | Failure isolation | None | Partial | Full | Every step to the right adds operational cost: distributed tracing, network failure handling, data consistency challenges, deployment orchestration. That cost is justified only when the benefit — independent scaling, team autonomy, fault isolation — outweighs it. ## Where Granit sits [Section titled “Where Granit sits”](#where-granit-sits) Granit is designed for modular monoliths. Every architectural decision — isolated DbContext per module, explicit `DependsOn` graphs, channel-based messaging — enforces module boundaries that mirror microservice boundaries. This is deliberate: when extraction becomes necessary, the seams already exist. The same `GranitModule` that runs inside a modular monolith can run as the entry point of an independent service. No rewrite required. ## Modular monolith with Granit [Section titled “Modular monolith with Granit”](#modular-monolith-with-granit) A single deployable process composed of multiple modules, each with clear boundaries. Program.cs ```csharp var builder = WebApplication.CreateBuilder(args); builder.AddGranit(granit => { // Core infrastructure granit.AddModule(); granit.AddModule(); granit.AddModule(); // Domain modules — each owns its DbContext granit.AddModule(); granit.AddModule(); granit.AddModule(); granit.AddModule(); // Application module granit.AddModule(); }); var app = builder.Build(); app.UseGranit(); app.Run(); ``` What this gives you: * **Module isolation** — each module declares its dependencies via `[DependsOn]`. Circular references are rejected at startup. * **Database isolation** — each `*.EntityFrameworkCore` module owns a separate `DbContext`. No shared tables, no accidental coupling. * **In-process messaging** — modules communicate through channels without requiring Wolverine or any external infrastructure. * **Single process** — inter-module calls are fast method invocations. No serialization, no network latency. * **Bundles** — related modules are grouped into bundles for convenience, but each module remains independently configurable. ## Microservices with Granit [Section titled “Microservices with Granit”](#microservices-with-granit) When a module needs independent scaling, a different deployment cadence, or team-level ownership, extract it into its own service. NotificationService/Program.cs ```csharp var builder = WebApplication.CreateBuilder(args); builder.AddGranit(granit => { granit.AddModule(); granit.AddModule(); granit.AddModule(); // This module now runs as an independent service granit.AddModule(); }); var app = builder.Build(); app.UseGranit(); app.Run(); ``` What changes: * **Wolverine replaces channels** — durable messaging over PostgreSQL transport handles inter-service communication with transactional outbox guarantees. * **Separate database** — the module already had its own `DbContext`. Now it gets its own PostgreSQL database. * **Independent deployment** — ship notification changes without redeploying the entire platform. * **Independent scaling** — scale notification processing horizontally without scaling the rest of the system. * **Multi-tenancy per service** — each service can apply its own tenant isolation strategy. What stays the same: * The `GranitModule` class and its `[DependsOn]` declarations. * Message handlers — a handler that processed channel messages processes Wolverine messages with zero code changes. * The `DbContext` and all entity configurations. * Observability — context propagation ensures distributed traces span services. ## Migration path [Section titled “Migration path”](#migration-path) The migration from modular monolith to extracted microservices is incremental. No big-bang rewrite. ``` graph TB subgraph "Step 1-2: Modular Monolith" M1["Module A"] -->|Channel| M2["Module B"] M1 -->|Channel| M3["Module C"] M2 -->|Channel| M3 end subgraph "Step 3-5: Extracted Service" N1["Module A"] -->|Wolverine| N2["Module B"] N1 -->|Channel| N3["Module C
(still in monolith)"] N2["Module B
(separate service)"] end style M1 fill:#e8eaf6,stroke:#3f51b5,color:#1a237e style M2 fill:#e8eaf6,stroke:#3f51b5,color:#1a237e style M3 fill:#e8eaf6,stroke:#3f51b5,color:#1a237e style N1 fill:#e8eaf6,stroke:#3f51b5,color:#1a237e style N2 fill:#fff3e0,stroke:#f57c00,color:#e65100 style N3 fill:#e8eaf6,stroke:#3f51b5,color:#1a237e ``` * Step 1: Start modular Deploy all modules in a single process. Use channel-based messaging for inter-module communication. This is the default and covers most teams. * Step 2: Add durability When you need guaranteed message delivery (e.g., notification fan-out must not lose messages), add `GranitWolverinePostgresqlModule`. The transactional outbox ensures messages survive process crashes. Your handlers do not change. * Step 3: Identify the candidate Look for modules with: different scaling requirements, a different release cadence, a dedicated team, or regulatory isolation needs (e.g., a payment module that requires PCI DSS scope reduction). * Step 4: Extract the service Create a new host project for the extracted module. It gets its own `Program.cs`, its own `GranitModule` composition, and Wolverine for messaging back to the monolith. * Step 5: Split the database Because each module already owns an isolated `DbContext`, the database split is mechanical. Point the extracted service’s connection string to a new database and run its migrations independently. ## Decision framework [Section titled “Decision framework”](#decision-framework) The question is never “should we use microservices?” — it is “does extracting this specific module justify the operational cost?” * Stay monolith * Fewer than 5 developers working on the codebase * Under 100k requests per minute * Single team with a shared release cycle * No module requires independent scaling * Deployment frequency is uniform across modules * Consider extraction * A module has fundamentally different scaling needs (e.g., notification delivery spikes) * A module has a different deployment cadence (daily vs. weekly) * Team boundaries align with module boundaries * Regulatory isolation requires a reduced compliance scope (e.g., PCI DSS for payments) * A module failure should not take down the entire system Warning Microservices are not a goal. They are a trade-off. The operational cost is real — monitoring, distributed tracing, network failures, data consistency, deployment orchestration, contract versioning. Only extract when the benefit outweighs the cost. A well-structured modular monolith outperforms a poorly operated microservices architecture every time. ## Why Granit works in both modes [Section titled “Why Granit works in both modes”](#why-granit-works-in-both-modes) Granit does not bet on one architecture. It enforces patterns that work regardless of deployment topology: | Pattern | Monolith benefit | Microservice benefit | | ----------------------------------- | ------------------------------------ | ---------------------------------- | | Isolated `DbContext` per module | No accidental table coupling | Database split is mechanical | | `[DependsOn]` graph | Startup validation, no circular refs | Explicit service contracts | | Channel / Wolverine messaging | In-process decoupling | Durable cross-service messaging | | Context propagation (OpenTelemetry) | Structured logs per module | Distributed traces across services | | Multi-tenancy (`ICurrentTenant`) | Tenant-scoped queries | Per-service tenant strategy | | `GranitModule` as entry point | Composable monolith | Independent service host | The key insight: the upgrade from channel to Wolverine requires zero handler code changes. You change the infrastructure registration, not the business logic. This is the difference between an architecture that supports migration and one that requires a rewrite. ## Further reading [Section titled “Further reading”](#further-reading) * [Module System](/concepts/module-system/) — how `GranitModule` and `[DependsOn]` work * [Messaging](/concepts/messaging/) — channels, Wolverine, and the handler model * [Wolverine Optionality](/concepts/wolverine-optionality/) — why Wolverine is optional and how to upgrade * [Wolverine reference](/reference/modules/wolverine/) — configuration, PostgreSQL transport, transactional outbox # Module System > How Granit organizes 100+ packages into a dependency graph with topological loading, lifecycle hooks, and conditional activation ## The problem [Section titled “The problem”](#the-problem) ASP.NET Core gives you dependency injection but no concept of a **module**. When a framework grows to 135 packages, you hit real problems fast: * **Ordering** — packages must register services in the right sequence (a caching module before a module that depends on it). * **Lifecycle** — some setup happens at DI registration time, other setup happens after `builder.Build()` (middleware, endpoints). * **Conditional loading** — Vault is useless in local development, Redis is optional if no connection string is configured. * **Discovery** — you need a single entry point that pulls in the entire dependency tree without listing every package by hand. Granit solves this with a lightweight module system inspired by ABP Framework, built on four primitives: `GranitModule`, `[DependsOn]`, topological sort, and a two-phase lifecycle. ## Core concepts [Section titled “Core concepts”](#core-concepts) ### Every package is a module [Section titled “Every package is a module”](#every-package-is-a-module) Each Granit NuGet package exposes exactly one class that inherits from `GranitModule`. This class is the package’s entry point — it declares dependencies and implements lifecycle hooks. ```csharp [DependsOn(typeof(GranitPersistenceModule))] [DependsOn(typeof(GranitCachingModule))] public sealed class GranitNotificationsModule : GranitModule { public override void ConfigureServices(ServiceConfigurationContext context) { context.Services.AddGranitNotifications(); } public override void OnApplicationInitialization(ApplicationInitializationContext context) { // Map endpoints, register middleware, etc. } } ``` ### Dependency declaration with `[DependsOn]` [Section titled “Dependency declaration with \[DependsOn\]”](#dependency-declaration-with-dependson) The `[DependsOn]` attribute accepts one or more module types. Multiple attributes can be stacked. The attribute is inherited, so a module automatically includes its base class dependencies. ### Topological sort [Section titled “Topological sort”](#topological-sort) `ModuleLoader` walks the `[DependsOn]` graph starting from your root module, discovers all reachable modules, deduplicates them, and sorts them using **Kahn’s algorithm**. Dependencies are loaded first; the root module is loaded last. Circular dependencies throw an `InvalidOperationException` at startup with the names of the involved modules. ### Two-phase lifecycle [Section titled “Two-phase lifecycle”](#two-phase-lifecycle) ``` sequenceDiagram participant Host as Program.cs participant Loader as ModuleLoader participant ModA as Module A (leaf) participant ModB as Module B (root) Host->>Loader: builder.AddGranit(ModB) Loader->>Loader: Discover + topological sort Note over Loader: Phase 1 — ConfigureServices Loader->>ModA: IsEnabled(context)? ModA-->>Loader: true Loader->>ModA: ConfigureServices(context) Loader->>ModB: IsEnabled(context)? ModB-->>Loader: true Loader->>ModB: ConfigureServices(context) Host->>Host: app = builder.Build() Note over Host: Phase 2 — OnApplicationInitialization Host->>ModA: OnApplicationInitialization(context) Host->>ModB: OnApplicationInitialization(context) ``` **Phase 1 — `ConfigureServices`** runs before `builder.Build()`. The `ServiceConfigurationContext` gives you access to: | Property | Type | Purpose | | ------------------ | ------------------------------ | ---------------------------------------------------- | | `Services` | `IServiceCollection` | DI registration | | `Configuration` | `IConfiguration` | appsettings, env vars | | `Builder` | `IHostApplicationBuilder` | Full builder (needed by Observability, etc.) | | `ModuleAssemblies` | `IReadOnlyList` | All loaded module assemblies for convention scanning | | `Items` | `IDictionary` | Shared state for inter-module communication | **Phase 2 — `OnApplicationInitialization`** runs after `Build()`, before `app.Run()`. The `ApplicationInitializationContext` provides the resolved `IServiceProvider`. Both phases have async variants (`ConfigureServicesAsync`, `OnApplicationInitializationAsync`) for modules that need to read remote config or verify connectivity at startup. ### Conditional modules [Section titled “Conditional modules”](#conditional-modules) Override `IsEnabled` to skip a module’s lifecycle methods based on configuration or environment. The module stays in the dependency graph (its dependents still load), but its `ConfigureServices` and `OnApplicationInitialization` are not called. * Environment-based ```csharp // Vault is disabled in Development — no Vault server needed locally [DependsOn(typeof(GranitEncryptionModule))] public sealed class GranitVaultModule : GranitModule { public override bool IsEnabled(ServiceConfigurationContext context) => !context.Builder.Environment.IsDevelopment(); public override void ConfigureServices(ServiceConfigurationContext context) { context.Services.AddGranitVault(); } } ``` * Config-based ```csharp // Redis caching activates only when configured [DependsOn(typeof(GranitCachingModule))] public sealed class GranitCachingRedisModule : GranitModule { public override bool IsEnabled(ServiceConfigurationContext context) { RedisCachingOptions redisOpts = context.Configuration .GetSection(RedisCachingOptions.SectionName) .Get() ?? new RedisCachingOptions(); return redisOpts.IsEnabled; } public override void ConfigureServices(ServiceConfigurationContext context) => context.Services.AddGranitCachingRedis(); } ``` ### Single entry point [Section titled “Single entry point”](#single-entry-point) Your `Program.cs` needs exactly one call: ```csharp var builder = WebApplication.CreateBuilder(args); builder.AddGranit(); var app = builder.Build(); await app.UseGranit(); await app.RunAsync(); ``` `AddGranit()` discovers every module reachable from `AppHostModule`, sorts them, and runs Phase 1. `UseGranit()` runs Phase 2. That is the entire bootstrap. A fluent builder API is also available when you need to compose modules from multiple independent roots: ```csharp builder.AddGranit(granit => granit .AddModule() .AddModule() .AddModule() ); ``` ## Soft dependencies [Section titled “Soft dependencies”](#soft-dependencies) Not every cross-cutting concern requires a hard `[DependsOn]`. The `ICurrentTenant` interface lives in `Granit.Core.MultiTenancy` and is available to every module without referencing `Granit.MultiTenancy`. A `NullTenantContext` (where `IsAvailable` returns `false`) is registered by default during `AddGranit`. This means a module like `Granit.BlobStorage.EntityFrameworkCore` can inject `ICurrentTenant?` and apply tenant filters without declaring `[DependsOn(typeof(GranitMultiTenancyModule))]`. The hard dependency is reserved for modules that **must enforce** tenant isolation (e.g., BlobStorage throws if no tenant context is available, for GDPR reasons). Warning Always check `ICurrentTenant.IsAvailable` before accessing `Id`. The null object is the normal state when multi-tenancy is not installed. ## How it fits together [Section titled “How it fits together”](#how-it-fits-together) The module graph **is** your architecture diagram. Every `[DependsOn]` is a compile-time-visible edge. The topological sort guarantees that if module B depends on module A, A’s services are registered first. No runtime surprises, no ordering bugs. ## Next steps [Section titled “Next steps”](#next-steps) * [Core reference](/reference/modules/core/) — full API surface of `Granit.Core` * [Bundles](./bundles/) — pre-composed module groups for common scenarios # Multi-Tenancy > Tenant resolution, isolation strategies (shared DB, schema-per-tenant, database-per-tenant), and transparent query filters ## The problem [Section titled “The problem”](#the-problem) SaaS applications serve multiple organizations from a single deployment. Without framework support, every repository method ends up with `WHERE TenantId = @tenantId` scattered across the codebase. Miss one filter and you leak data between tenants — a GDPR incident waiting to happen. Granit solves this with **transparent tenant isolation**: the framework resolves the current tenant, stores it in an async-safe context, and automatically appends query filters to every EF Core query. Application code never writes a tenant WHERE clause. ## Tenant resolution [Section titled “Tenant resolution”](#tenant-resolution) The middleware pipeline resolves the tenant identity on every request. Two built-in resolvers run in priority order — **first match wins**: | Order | Resolver | Source | Use case | | ----- | ------------------------ | --------------------- | -------------------------------------- | | 100 | `HeaderTenantResolver` | `X-Tenant-Id` header | Service-to-service calls, API gateways | | 200 | `JwtClaimTenantResolver` | `tenant_id` JWT claim | End-user requests via Keycloak | ### Custom resolvers [Section titled “Custom resolvers”](#custom-resolvers) Implement `ITenantResolver` to add your own resolution logic (e.g., subdomain-based): ```csharp public class SubdomainTenantResolver : ITenantResolver { public int Order => 50; // Runs before header and JWT resolvers public Task ResolveAsync(HttpContext context, CancellationToken cancellationToken) { var host = context.Request.Host.Host; var subdomain = host.Split('.')[0]; // Look up tenant ID from subdomain — your logic here return Task.FromResult(null); } } ``` Register it in your module: ```csharp services.AddTransient(); ``` ## Async-safe tenant context [Section titled “Async-safe tenant context”](#async-safe-tenant-context) `ICurrentTenant` is backed by `AsyncLocal`. This means: * The tenant context **propagates** through `async`/`await` calls automatically. * Parallel tasks (`Task.WhenAll`, `Parallel.ForEachAsync`) each get their own isolated copy — no cross-contamination. * Background jobs must explicitly set the tenant context before executing tenant-scoped work. ```csharp public class InvoiceService(ICurrentTenant currentTenant) { public void PrintCurrentTenant() { if (!currentTenant.IsAvailable) { // No tenant context — running in a host-level scope return; } var tenantId = currentTenant.Id; // Guid } } ``` Warning Always check `ICurrentTenant.IsAvailable` before using `Id`. `NullTenantContext` (`IsAvailable = false`) is the normal state when multi-tenancy is not installed. Accessing `Id` without checking throws `InvalidOperationException`. ## Isolation strategies [Section titled “Isolation strategies”](#isolation-strategies) Granit supports three isolation strategies. You choose per deployment — or mix them dynamically for different tenant tiers. ### 1. SharedDatabase [Section titled “1. SharedDatabase”](#1-shareddatabase) All tenants share one database. Isolation is purely logical via `WHERE TenantId = @id` query filters. * Simplest to operate and migrate. * Scales to **1 000+ tenants** without connection pool pressure. * Single backup/restore covers all tenants. * Not suitable when regulations require physical data separation. ### 2. SchemaPerTenant [Section titled “2. SchemaPerTenant”](#2-schemapertenant) Each tenant gets its own PostgreSQL schema within a shared database. Tables are identical, but isolated at the schema level. * Practical limit: **\~1 000 tenants** (PostgreSQL catalog overhead). * Per-tenant backup possible via `pg_dump --schema`. * Migrations must run per schema — Granit handles this automatically. ### 3. DatabasePerTenant [Section titled “3. DatabasePerTenant”](#3-databasepertenant) Each tenant gets a dedicated database. Full physical separation. * Required for **ISO 27001** when the client demands physical isolation. * Practical limit: **\~200 tenants** with PgBouncer connection pooling. * Independent backup, restore, and retention policies per tenant. * Highest operational cost. ### Decision matrix [Section titled “Decision matrix”](#decision-matrix) ``` flowchart TD A[New tenant onboarding] --> B{"ISO 27001 physical
separation required?"} B -- Yes --> C[DatabasePerTenant] B -- No --> D{"More than 1000
tenants expected?"} D -- Yes --> E[SharedDatabase] D -- No --> F[SchemaPerTenant] ``` ### Dynamic strategy [Section titled “Dynamic strategy”](#dynamic-strategy) Premium and standard tenants can coexist in the same deployment. Configure the strategy per tenant in the tenant registry: ```csharp public class Tenant { public Guid Id { get; set; } public string Name { get; set; } = string.Empty; public TenantIsolationStrategy Strategy { get; set; } public string? ConnectionString { get; set; } // For DatabasePerTenant public string? Schema { get; set; } // For SchemaPerTenant } ``` The `TenantConnectionStringResolver` reads the strategy and returns the appropriate connection string (or schema) at runtime. ## Transparent query filters [Section titled “Transparent query filters”](#transparent-query-filters) When you call `modelBuilder.ApplyGranitConventions(currentTenant, dataFilter)` in your `DbContext.OnModelCreating`, the framework automatically registers a `HasQueryFilter` for every entity implementing `IMultiTenant`: ```sql -- Generated by EF Core query filter, not written by hand WHERE "t"."TenantId" = @__currentTenant_Id ``` This filter combines with other Granit filters (`ISoftDeletable`, `IActive`, `IPublishable`, `IProcessingRestrictable`) into a single `HasQueryFilter` expression per entity. You never write manual `HasQueryFilter` calls — `ApplyGranitConventions` handles all of them centrally. ## Implementing a multi-tenant entity [Section titled “Implementing a multi-tenant entity”](#implementing-a-multi-tenant-entity) Entities opt into tenant isolation by implementing `IMultiTenant`: ```csharp using Granit.Core.Domain; using Granit.Core.MultiTenancy; public class Invoice : AggregateRoot, IMultiTenant { public Guid? TenantId { get; set; } public string Number { get; set; } = string.Empty; public decimal Amount { get; set; } public DateTimeOffset IssuedAt { get; set; } } ``` Warning `TenantId` must be `Guid?` (nullable), never `string`. The interface contract in `Granit.Core.Domain` enforces this. Non-nullable `Guid` will fail the architecture tests. ## Soft dependency rule [Section titled “Soft dependency rule”](#soft-dependency-rule) Granit modules that need to read `ICurrentTenant` should reference `Granit.Core.MultiTenancy` — **not** `Granit.MultiTenancy`. There is no need to add `[DependsOn(typeof(GranitMultiTenancyModule))]` or a `` to the `Granit.MultiTenancy` package. A `NullTenantContext` is registered by default in every Granit application. It returns `IsAvailable = false` and acts as the null object when multi-tenancy is not installed. Hard dependency on `Granit.MultiTenancy` is allowed **only** when a module must enforce strict tenant isolation (e.g., `Granit.BlobStorage` throws if no tenant context exists — required for GDPR data segregation). ```csharp // Correct — soft dependency via Granit.Core using Granit.Core.MultiTenancy; public class ReportService(ICurrentTenant currentTenant) { public async Task GenerateAsync(CancellationToken cancellationToken) { if (currentTenant.IsAvailable) { // Tenant-scoped logic } // Host-level fallback // ... } } ``` ## Further reading [Section titled “Further reading”](#further-reading) * [Multi-Tenancy reference](/reference/modules/multi-tenancy/) — configuration, API surface, and extension methods * [Persistence concept](./persistence/) — how query filters and interceptors integrate with tenant isolation # Persistence > EF Core interceptors for audit trails, soft delete, versioning — and the isolated DbContext pattern that makes it all work ## The problem [Section titled “The problem”](#the-problem) Every module in a modular framework needs audit trails, soft delete, and tenant isolation. Without centralized support, developers end up copying the same boilerplate: setting `CreatedAt` in every repository, writing `WHERE IsDeleted = false` in every query, forgetting `ModifiedBy` on updates. One missed filter and you surface deleted records or leak data across tenants. Granit eliminates this by intercepting EF Core’s `SaveChanges` pipeline and auto-applying query filters. Application code deals with domain logic; the framework handles the plumbing. ## EF Core interceptors [Section titled “EF Core interceptors”](#ef-core-interceptors) Three `SaveChangesInterceptor` implementations run in sequence before every `SaveChanges` / `SaveChangesAsync` call: ``` flowchart LR A[SaveChanges called] --> B[AuditedEntityInterceptor] B --> C[SoftDeleteInterceptor] C --> D[VersioningInterceptor] D --> E[Database] ``` ### 1. AuditedEntityInterceptor [Section titled “1. AuditedEntityInterceptor”](#1-auditedentityinterceptor) Targets entities implementing `IAuditedEntity`. On every save: | State | Fields set | | -------- | -------------------------------------------------------- | | Added | `CreatedAt`, `CreatedBy`, `TenantId` (if `IMultiTenant`) | | Modified | `ModifiedAt`, `ModifiedBy` | Timestamps come from the injected `TimeProvider` (never `DateTime.Now`). User identity comes from `ICurrentUserService`. Tenant ID comes from `ICurrentTenant`. ```csharp public class Invoice : AggregateRoot, IAuditedEntity, IMultiTenant { public Guid? TenantId { get; set; } public string Number { get; set; } = string.Empty; public decimal Amount { get; set; } // These are set automatically — never assign them manually public DateTimeOffset CreatedAt { get; set; } public string? CreatedBy { get; set; } public DateTimeOffset? ModifiedAt { get; set; } public string? ModifiedBy { get; set; } } ``` ### 2. SoftDeleteInterceptor [Section titled “2. SoftDeleteInterceptor”](#2-softdeleteinterceptor) Targets entities implementing `ISoftDeletable`. When EF Core detects a `Deleted` state, the interceptor: 1. Changes the state from `Deleted` to `Modified`. 2. Sets `IsDeleted = true`, `DeletedAt`, and `DeletedBy`. 3. The row stays in the database — no physical DELETE is issued. ```csharp public class Document : AggregateRoot, ISoftDeletable { public string Title { get; set; } = string.Empty; // Managed by the interceptor public bool IsDeleted { get; set; } public DateTimeOffset? DeletedAt { get; set; } public string? DeletedBy { get; set; } } ``` ### 3. VersioningInterceptor [Section titled “3. VersioningInterceptor”](#3-versioninginterceptor) Targets entities implementing `IVersionedEntity`. On insert: * Assigns a `BusinessId` (human-readable sequential identifier scoped to the entity type). * Sets `Version` to `max(Version) + 1` for that `BusinessId`. This gives you an immutable version history without a separate history table. ## Automatic query filters [Section titled “Automatic query filters”](#automatic-query-filters) `ApplyGranitConventions` scans all entity types in the model and registers a single `HasQueryFilter` per entity. The filter combines all applicable interfaces with AND logic: | Interface | Filter | | ------------------------- | -------------------------------------- | | `ISoftDeletable` | `WHERE IsDeleted = false` | | `IActive` | `WHERE IsActive = true` | | `IMultiTenant` | `WHERE TenantId = @currentTenantId` | | `IProcessingRestrictable` | `WHERE IsProcessingRestricted = false` | | `IPublishable` | `WHERE PublicationStatus = Published` | An entity implementing both `ISoftDeletable` and `IMultiTenant` gets: ```sql WHERE "e"."IsDeleted" = FALSE AND "e"."TenantId" = @__tenantId ``` Warning Never add manual `HasQueryFilter` calls in your entity configurations. EF Core supports only one `HasQueryFilter` per entity — the last registration wins. Manual filters silently override the framework’s combined filter, breaking tenant isolation or soft delete. Let `ApplyGranitConventions` own all query filters. ## Isolated DbContext checklist [Section titled “Isolated DbContext checklist”](#isolated-dbcontext-checklist) Every `*.EntityFrameworkCore` package that owns a `DbContext` **must** follow this checklist. No exceptions — the architecture tests enforce it. ### 1. ProjectReference to Granit.Persistence [Section titled “1. ProjectReference to Granit.Persistence”](#1-projectreference-to-granitpersistence) ```xml ``` ### 2. Constructor injection [Section titled “2. Constructor injection”](#2-constructor-injection) Inject `ICurrentTenant?` and `IDataFilter?` — both optional, defaulting to `null`: ```csharp public class InvoiceDbContext( DbContextOptions options, ICurrentTenant? currentTenant = null, IDataFilter? dataFilter = null) : DbContext(options) { // ... } ``` ### 3. Call ApplyGranitConventions [Section titled “3. Call ApplyGranitConventions”](#3-call-applygranitconventions) At the end of `OnModelCreating`, after all entity configurations: ```csharp protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.ApplyConfigurationsFromAssembly(typeof(InvoiceDbContext).Assembly); modelBuilder.ApplyGranitConventions(currentTenant, dataFilter); } ``` ### 4. Wire interceptors in the extension method [Section titled “4. Wire interceptors in the extension method”](#4-wire-interceptors-in-the-extension-method) Use the `(IServiceProvider, DbContextOptionsBuilder)` overload of `AddDbContextFactory` with `ServiceLifetime.Scoped`. Resolve interceptors from the service provider: ```csharp public static IServiceCollection AddInvoicePersistence( this IServiceCollection services, string connectionString) { services.AddDbContextFactory((sp, options) => { options.UseNpgsql(connectionString); options.AddInterceptors( sp.GetRequiredService(), sp.GetRequiredService()); }, ServiceLifetime.Scoped); return services; } ``` ### 5. DependsOn attribute [Section titled “5. DependsOn attribute”](#5-dependson-attribute) ```csharp [DependsOn(typeof(GranitPersistenceModule))] public class InvoiceEntityFrameworkCoreModule : GranitModule { // ... } ``` ### 6. No manual HasQueryFilter [Section titled “6. No manual HasQueryFilter”](#6-no-manual-hasqueryfilter) Already covered above. `ApplyGranitConventions` handles all standard filters. Manual filters cause duplicates or silent overrides. ### 7. TenantId type [Section titled “7. TenantId type”](#7-tenantid-type) `IMultiTenant` entities use `Guid? TenantId` — never `string`, never non-nullable `Guid`. The nullable type is required because host-level entities (shared across tenants) legitimately have no tenant. ## Data seeding [Section titled “Data seeding”](#data-seeding) Granit provides `IDataSeedContributor` for deterministic, idempotent seed data: ```csharp public class InvoiceStatusSeedContributor : IDataSeedContributor { public int Order => 100; // Controls execution sequence public async Task SeedAsync( DataSeedContext context, CancellationToken cancellationToken) { var dbContext = context.ServiceProvider .GetRequiredService(); if (await dbContext.InvoiceStatuses.AnyAsync(cancellationToken)) { return; // Idempotent — skip if data exists } dbContext.InvoiceStatuses.AddRange( new InvoiceStatus { Code = "DRAFT", LabelEn = "Draft", LabelFr = "Brouillon" }, new InvoiceStatus { Code = "SENT", LabelEn = "Sent", LabelFr = "Envoyee" }); await dbContext.SaveChangesAsync(cancellationToken); } } ``` ## Putting it all together [Section titled “Putting it all together”](#putting-it-all-together) Here is what happens when you call `SaveChangesAsync` on an entity that implements `IAuditedEntity`, `ISoftDeletable`, and `IMultiTenant`: **On insert:** 1. `AuditedEntityInterceptor` sets `CreatedAt`, `CreatedBy`, `TenantId`. 2. `SoftDeleteInterceptor` — no action (entity is not being deleted). 3. Row is inserted. **On update:** 1. `AuditedEntityInterceptor` sets `ModifiedAt`, `ModifiedBy`. 2. `SoftDeleteInterceptor` — no action. 3. Row is updated. **On delete:** 1. `AuditedEntityInterceptor` sets `ModifiedAt`, `ModifiedBy`. 2. `SoftDeleteInterceptor` changes state to `Modified`, sets `IsDeleted`, `DeletedAt`, `DeletedBy`. 3. Row is updated (not deleted). **On query:** 1. EF Core applies the combined query filter: `WHERE IsDeleted = false AND TenantId = @tenantId` 2. Application code sees only active, non-deleted, tenant-scoped records. ## Further reading [Section titled “Further reading”](#further-reading) * [Persistence reference](/reference/modules/persistence/) — configuration options, migration commands, and API surface * [Multi-Tenancy concept](./multi-tenancy/) — tenant resolution and isolation strategies * [Compliance concept](./compliance/) — how audit trails and soft delete support GDPR and ISO 27001 # Security Model > JWT authentication, Keycloak, Entra ID, and AWS Cognito integration, RBAC authorization with per-role caching, and back-channel logout ## The problem [Section titled “The problem”](#the-problem) Modern APIs need three things from their security stack: * **Pluggable authentication** — swap Keycloak for Entra ID or Cognito without touching application code. * **Fine-grained authorization** — control access at the resource-action level without per-user permission assignments that spiral into unmanageable matrices. * **Compliance-ready audit trails** — every authenticated action must be traceable for ISO 27001 and GDPR. Most frameworks punt on this: they give you `[Authorize]` and leave the rest to you. Granit provides a layered security stack where each layer has a single responsibility and can be replaced independently. ## Authentication stack [Section titled “Authentication stack”](#authentication-stack) The authentication pipeline is built from a base package with provider-specific modules layered on top. Each provider module adds one concern: claims transformation from the provider’s JWT format to standard .NET role claims. ``` graph TD S["Granit.Security
ICurrentUserService abstraction"] --> C[Granit.Core] JWT["Granit.Authentication.JwtBearer
Generic OIDC JWT Bearer"] --> S KC["Granit.Authentication.Keycloak
Keycloak claims transformation"] --> JWT EID["Granit.Authentication.EntraId
Entra ID roles parsing"] --> JWT CG["Granit.Authentication.Cognito
Cognito groups → roles"] --> JWT ``` ### Granit.Security — the foundation [Section titled “Granit.Security — the foundation”](#granitsecurity--the-foundation) `ICurrentUserService` is the sole abstraction for “who is calling.” It lives in `Granit.Security` with zero dependency on ASP.NET Core, so domain services and background jobs can consume it without pulling in the HTTP stack. ```csharp public interface ICurrentUserService { string? UserId { get; } string? UserName { get; } string? Email { get; } bool IsAuthenticated { get; } ActorKind ActorKind { get; } IReadOnlyList GetRoles(); bool IsInRole(string role); } ``` `ActorKind` distinguishes `User` (human), `ExternalSystem` (API key or service account), and `System` (background jobs). The `AuditedEntityInterceptor` uses this to fill `CreatedBy`/`ModifiedBy` — when no user is authenticated, it falls back to `"system"`. ### Granit.Authentication.JwtBearer — generic OIDC [Section titled “Granit.Authentication.JwtBearer — generic OIDC”](#granitauthenticationjwtbearer--generic-oidc) This module registers ASP.NET Core JWT Bearer authentication, a `CurrentUserService` backed by `HttpContext.User`, and an `IRevokedSessionStore` for back-channel logout. It also registers the `"Authenticated"` authorization policy, which requires a valid JWT with no further claims. Any OIDC-compliant provider works out of the box: ```json { "Authentication": { "Authority": "https://idp.example.com", "Audience": "my-api", "RequireHttpsMetadata": true, "NameClaimType": "sub" } } ``` ### Granit.Authentication.Keycloak — PostConfigure pattern [Section titled “Granit.Authentication.Keycloak — PostConfigure pattern”](#granitauthenticationkeycloak--postconfigure-pattern) The Keycloak module does not register its own authentication scheme. Instead, it **post-configures** `JwtBearerOptions` to add Keycloak-specific claims transformation. This is the key design choice: the consumer declares a single `[DependsOn]`, and the module wires itself in automatically. ```csharp [DependsOn(typeof(GranitAuthenticationKeycloakModule))] public class AppModule : GranitModule { } ``` Behind the scenes, the module: 1. Reads `realm_access.roles` and `resource_access.{clientId}.roles` from the JWT payload. 2. Maps them to standard `ClaimTypes.Role` claims. 3. Registers an `"Admin"` authorization policy matching the configured admin role. No manual `PostConfigure` calls, no claims transformation boilerplate. ### Granit.Authentication.EntraId — Microsoft Entra ID [Section titled “Granit.Authentication.EntraId — Microsoft Entra ID”](#granitauthenticationentraid--microsoft-entra-id) For Azure-based deployments, the Entra ID module parses roles from both the v1.0 `roles` claim and the v2.0 `wids` claim. Like the Keycloak module, it post-configures `JwtBearerOptions` and maps provider-specific claims to standard `ClaimTypes.Role`. ### Granit.Authentication.Cognito — AWS Cognito [Section titled “Granit.Authentication.Cognito — AWS Cognito”](#granitauthenticationcognito--aws-cognito) For AWS-based deployments, the Cognito module extracts groups from the `cognito:groups` claim and maps them to standard `ClaimTypes.Role`. Cognito has no native “roles” concept — groups serve as roles. * Keycloak ```csharp [DependsOn(typeof(GranitAuthenticationKeycloakModule))] [DependsOn(typeof(GranitAuthorizationModule))] public class AppModule : GranitModule { } ``` * Entra ID ```csharp [DependsOn(typeof(GranitAuthenticationEntraIdModule))] [DependsOn(typeof(GranitAuthorizationModule))] public class AppModule : GranitModule { } ``` * AWS Cognito ```csharp [DependsOn(typeof(GranitAuthenticationCognitoModule))] [DependsOn(typeof(GranitAuthorizationModule))] public class AppModule : GranitModule { } ``` * Generic OIDC ```csharp [DependsOn(typeof(GranitJwtBearerModule))] [DependsOn(typeof(GranitAuthorizationModule))] public class AppModule : GranitModule { } ``` ## Authorization — RBAC strict [Section titled “Authorization — RBAC strict”](#authorization--rbac-strict) Granit enforces a strict rule: **all permissions are attached to roles, never to individual users.** This is not a suggestion — the framework provides no API for user-level permission grants. The reason is practical: user-level permissions create an N-users x M-permissions matrix that becomes unmanageable within months. ### Permission naming convention [Section titled “Permission naming convention”](#permission-naming-convention) Permissions follow the pattern `[Module].[Resource].[Action]`: | Action | Meaning | Example | | --------- | ---------------------------------- | ------------------------------ | | `Read` | View resources | `Invoices.Invoices.Read` | | `Create` | Create new resources | `Invoices.Invoices.Create` | | `Update` | Modify existing resources | `Invoices.Invoices.Update` | | `Delete` | Remove resources | `Invoices.Invoices.Delete` | | `Manage` | Full CRUD (implies all four above) | `Invoices.Invoices.Manage` | | `Execute` | Non-CRUD action | `DataExchange.Imports.Execute` | Modules declare their permissions via `IPermissionDefinitionProvider`: ```csharp public class InvoicePermissionDefinitionProvider : IPermissionDefinitionProvider { public void DefinePermissions(IPermissionDefinitionContext context) { var group = context.AddGroup("Invoices", "Invoice management"); group.AddPermission("Invoices.Invoices.Read", "View invoices"); group.AddPermission("Invoices.Invoices.Create", "Create invoices"); group.AddPermission("Invoices.Invoices.Update", "Edit invoices"); group.AddPermission("Invoices.Invoices.Delete", "Delete invoices"); } } ``` ### DynamicPermissionPolicyProvider [Section titled “DynamicPermissionPolicyProvider”](#dynamicpermissionpolicyprovider) ASP.NET Core requires a named `AuthorizationPolicy` for every `[Authorize(Policy = "...")]`. Registering hundreds of policies at startup is wasteful. Instead, `DynamicPermissionPolicyProvider` creates policies on-the-fly from the permission name. When ASP.NET Core asks for a policy named `Invoices.Invoices.Read`, the provider builds one that delegates to `IPermissionChecker`. ### Per-role caching [Section titled “Per-role caching”](#per-role-caching) Permission checks are cached **by role**, not by user. With K roles and M permissions, the cache holds K x M entries. Compare this to N users x M permissions in a user-level scheme — for a system with 10,000 users, 5 roles, and 200 permissions, that is 1,000 cache entries instead of 2,000,000. Cache key format: `perm:{tenantId}:{roleName}:{permissionName}` Default TTL: 5 minutes, configurable via `Authorization:CacheDuration`. ### AdminRoles bypass [Section titled “AdminRoles bypass”](#adminroles-bypass) Roles listed in `Authorization:AdminRoles` bypass all permission checks entirely. This is a configured list, not a hardcoded constant. The bypass is evaluated before the cache lookup, so admin requests never touch the permission store. ```json { "Authorization": { "AdminRoles": ["admin"], "CacheDuration": "00:05:00" } } ``` ### Permission checking pipeline [Section titled “Permission checking pipeline”](#permission-checking-pipeline) ``` flowchart LR A[Incoming request] --> B{AlwaysAllow?} B -->|yes| C[Granted] B -->|no| D{AdminRole?} D -->|yes| C D -->|no| E{Cache hit?} E -->|yes| F{Granted?} E -->|no| G[IPermissionGrantStore] G --> H[Cache result by role] H --> F F -->|yes| C F -->|no| I[Denied — 403] ``` The `PermissionChecker` evaluates in strict order: 1. **AlwaysAllow** — development-only escape hatch. The option validator rejects it outside `Development` environment. 2. **AdminRole bypass** — users with any role in `AdminRoles` skip all further checks. 3. **Per-role cache** — checks all roles the user holds, grants if any role has the permission. 4. **IPermissionGrantStore** — queries the backing store (EF Core or custom). Result is cached for subsequent requests. ## Back-channel logout [Section titled “Back-channel logout”](#back-channel-logout) Granit implements the [OIDC Back-Channel Logout](https://openid.net/specs/openid-connect-backchannel-1_0.html) specification, provider-agnostic. The flow: 1. User logs out from the identity provider. 2. The IdP POSTs a `logout_token` to your API at `/auth/back-channel-logout`. 3. The endpoint validates the token: signature (against IdP JWKS), issuer, audience. 4. The `sid` (session ID) claim is extracted and stored in `IDistributedCache` with key `granit:revoked-session:{sid}`. 5. Subsequent requests carrying a JWT with a revoked `sid` are rejected by the JWT Bearer events handler. ```csharp // In OnApplicationInitialization app.MapBackChannelLogout(); // POST /auth/back-channel-logout (anonymous) ``` Warning The back-channel logout endpoint must be anonymous — the IdP calls it directly. It is excluded from OpenAPI documentation by default. Make sure your reverse proxy allows unauthenticated POST requests to this path. ```json { "Authentication": { "BackChannelLogout": { "Enabled": true, "EndpointPath": "/auth/back-channel-logout", "SessionRevocationTtl": "01:00:00" } } } ``` ## Request flow [Section titled “Request flow”](#request-flow) The complete security pipeline for an authenticated request: ``` sequenceDiagram participant Client participant JWT as JWT Bearer Middleware participant CT as ClaimsTransformation participant DPP as DynamicPolicyProvider participant PC as PermissionChecker participant Cache as IDistributedCache participant Store as IPermissionGrantStore Client->>JWT: GET /invoices (Bearer token) JWT->>JWT: Validate signature, issuer, audience JWT->>JWT: Check sid not in revoked sessions JWT->>CT: Transform claims CT->>CT: Extract roles (Keycloak/EntraID/Cognito/generic) CT-->>JWT: ClaimsPrincipal with Role claims Note over DPP: [Permission("Invoices.Invoices.Read")] DPP->>PC: Check permission for user roles PC->>PC: AdminRole? Skip if yes PC->>Cache: Lookup perm:{tenantId}:{role}:{permission} alt Cache miss Cache-->>PC: miss PC->>Store: Query grants for role Store-->>PC: granted/denied PC->>Cache: Store result (TTL 5 min) else Cache hit Cache-->>PC: granted/denied end PC-->>Client: 200 OK or 403 Forbidden ``` ## Design decisions [Section titled “Design decisions”](#design-decisions) **Why no user-level permissions?** Permission creep. In every system we have built, user-level overrides start as “just one exception” and end as an unauditable mess. Role-based grants are reviewable: you can answer “what can role X do?” in one query. “What can user Y do?” across 50 individual grants is a different story. **Why cache by role, not by user?** A role’s permissions change rarely (admin action). A user’s role membership changes more often (HR onboarding). By caching at the role level, a permission grant change invalidates K cache entries (one per role), not N entries (one per user). **Why PostConfigure instead of a separate auth scheme?** ASP.NET Core’s default authentication scheme is the one that runs on `[Authorize]`. If the Keycloak module registered its own scheme, every endpoint would need `[Authorize(AuthenticationSchemes = "Keycloak")]`. PostConfigure modifies the default JWT Bearer scheme in place, so `[Authorize]` just works. ## Next steps [Section titled “Next steps”](#next-steps) * [Security reference](/reference/modules/security/) — full API surface, configuration tables, EF Core permission store * [Compliance concept](./compliance/) — how the security model supports GDPR and ISO 27001 * [Identity reference](/reference/modules/identity/) — user management via Keycloak Admin API or Cognito User Pool API # Wolverine Optionality > What works without Wolverine, the Channel fallback pattern, and when you actually need a message bus ## The problem [Section titled “The problem”](#the-problem) Not every project needs a full message bus. A small internal API serving a single team has no use for a PostgreSQL-backed transactional outbox, durable delivery, or distributed transport. But that same API might still need background jobs, webhook delivery, or notification dispatch. Forcing Wolverine on every project would mean unnecessary infrastructure (PostgreSQL transport tables, outbox polling, dead-letter queues) for applications that will never need durability guarantees. On the other hand, baking in-memory-only dispatch into the framework would leave production systems without crash recovery. Granit’s answer: make Wolverine optional everywhere, with a `Channel` fallback that works out of the box. ## The Channel fallback pattern [Section titled “The Channel fallback pattern”](#the-channel-fallback-pattern) Four modules ship with both a Wolverine integration package and a built-in `Channel` dispatcher: | Module | Channel dispatcher | Wolverine package | | ----------------------- | ------------------------------------------------------------------- | --------------------------------- | | `Granit.BackgroundJobs` | `ChannelBackgroundJobDispatcher` | `Granit.BackgroundJobs.Wolverine` | | `Granit.Notifications` | `ChannelNotificationPublisher` | `Granit.Notifications.Wolverine` | | `Granit.Webhooks` | `ChannelWebhookCommandDispatcher` | `Granit.Webhooks.Wolverine` | | `Granit.DataExchange` | `ChannelImportCommandDispatcher` / `ChannelExportCommandDispatcher` | `Granit.DataExchange.Wolverine` | Each base module registers its `Channel` dispatcher by default. When the corresponding Wolverine package is added and its module is declared in `[DependsOn]`, it **replaces** the Channel dispatcher with a durable outbox-backed implementation. No code changes in your handlers. ### How Channel dispatch works [Section titled “How Channel dispatch works”](#how-channel-dispatch-works) The pattern is the same across all four modules. Taking background jobs as an example: ```csharp // ChannelBackgroundJobDispatcher (simplified) internal sealed class ChannelBackgroundJobDispatcher( Channel channel, TimeProvider timeProvider) : IBackgroundJobDispatcher { public async Task PublishAsync( object message, IDictionary? headers = null, CancellationToken cancellationToken = default) => await channel.Writer.WriteAsync( new BackgroundJobEnvelope(message, headers), cancellationToken) .ConfigureAwait(false); } ``` A `BackgroundJobWorker` (hosted service) reads from the channel and executes handlers in-process. The same pattern applies to `NotificationDispatchWorker`, `WebhookDispatchWorker`, and the DataExchange workers. ## When you need what [Section titled “When you need what”](#when-you-need-what) | Requirement | Without Wolverine | With Wolverine | | ----------------------------------------- | ---------------------------- | -------------------------- | | Fire-and-forget jobs | Channel (in-memory) | Durable outbox | | Scheduled/recurring jobs | `Task.Delay` (lost on crash) | Cron + outbox (crash-safe) | | At-least-once delivery | No | Yes | | Transactional outbox | No | Yes | | Distributed tracing across async handlers | No | Yes (context propagation) | | Horizontal scaling (multiple instances) | No (in-process only) | Yes (PostgreSQL transport) | | Dead-letter queue inspection | No | Yes (admin endpoints) | ### When Channel dispatch is enough [Section titled “When Channel dispatch is enough”](#when-channel-dispatch-is-enough) * Internal tools and small APIs with a single instance * Development and testing environments * Applications where losing a few in-flight messages on crash is acceptable * Prototyping — get the feature working first, add durability later ### When you need Wolverine [Section titled “When you need Wolverine”](#when-you-need-wolverine) * Production systems that require at-least-once delivery (webhook delivery, payment notifications) * Multi-instance deployments where only one instance should run a recurring job * ISO 27001 environments that mandate audit trail completeness through crash recovery * Applications that need the transactional outbox to avoid dual-write problems Channel dispatch is not durable Messages in the `Channel` pipeline live in process memory. If the application crashes or restarts, in-flight messages are lost. There is no retry, no dead-letter queue, no persistence. For anything that must not be lost, use the Wolverine package. ## Adding Wolverine later [Section titled “Adding Wolverine later”](#adding-wolverine-later) The migration path is deliberately simple. No handler code changes, no interface changes — just add packages and update `[DependsOn]`. * Before (Channel only) ```csharp [DependsOn(typeof(GranitBackgroundJobsModule))] [DependsOn(typeof(GranitNotificationsModule))] public class AppModule : GranitModule { } ``` * After (Wolverine durable) ```csharp [DependsOn(typeof(GranitBackgroundJobsWolverineModule))] [DependsOn(typeof(GranitNotificationsWolverineModule))] [DependsOn(typeof(GranitWolverinePostgresqlModule))] public class AppModule : GranitModule { } ``` The Wolverine modules transitively depend on their base modules, so `GranitBackgroundJobsWolverineModule` pulls in `GranitBackgroundJobsModule` automatically. The Channel dispatcher is replaced by the Wolverine dispatcher at DI registration time. Add the PostgreSQL transport connection string: ```json { "WolverinePostgresql": { "TransportConnectionString": "Host=db;Database=myapp;Username=app;Password=..." } } ``` That is the entire migration. Your handlers, your cron expressions, your notification templates — everything else stays the same. ## Architecture [Section titled “Architecture”](#architecture) ``` graph TD subgraph "Without Wolverine" H1[Handler] --> CD[Channel Dispatcher] CD --> CW[Channel Worker] CW --> H2[Handler execution] end subgraph "With Wolverine" H3[Handler] --> WD[Wolverine Dispatcher] WD --> OB[Outbox - same TX] OB --> TR[PostgreSQL Transport] TR --> H4[Handler execution] end style CD fill:#f9f,stroke:#333 style WD fill:#9f9,stroke:#333 ``` Both paths implement the same `IBackgroundJobDispatcher` / `INotificationPublisher` / `IWebhookPublisher` interfaces. Consumer code is identical. ## See also [Section titled “See also”](#see-also) * [Messaging](./messaging/) — domain events, integration events, transactional outbox, context propagation * [Wolverine reference](/reference/modules/wolverine/) — full API surface, setup variants, handler conventions * [BackgroundJobs reference](/reference/modules/background-jobs/) — recurring job scheduling with cron expressions # Contributing to Granit > How to contribute to the Granit framework — guidelines, setup, and conventions Thank you for your interest in contributing to Granit. This section covers everything you need to get started as a contributor to the framework. Before contributing, please read the [Code of Conduct](https://github.com/granit-fx/granit-dotnet/blob/main/CODE_OF_CONDUCT.md) (Contributor Covenant 2.1). We are committed to providing a welcoming and inclusive experience for everyone. ## How to contribute [Section titled “How to contribute”](#how-to-contribute) ### Reporting bugs [Section titled “Reporting bugs”](#reporting-bugs) Open an issue using the **Bug** template. Include: * A clear, concise description of the problem * Steps to reproduce * Expected vs actual behavior * .NET version and OS ### Suggesting features [Section titled “Suggesting features”](#suggesting-features) Open an issue using the **Feature** template. Describe: * The use case and motivation * How it fits into Granit’s modular architecture * Any alternatives you considered ### Submitting changes [Section titled “Submitting changes”](#submitting-changes) 1. **Fork** the repository 2. **Create a branch** from `develop` (see [Git Workflow](./git-workflow.md)) 3. **Write your code** following the [Coding Standards](./coding-standards.md) 4. **Write or update tests** (see [Testing Guide](./testing-guide.md)) 5. **Run the [Definition of Done](./definition-of-done.md) checks** 6. **Commit** using Conventional Commits 7. **Open a merge request** against `develop` ## Section contents [Section titled “Section contents”](#section-contents) * [Development Setup](./development-setup.md) — prerequisites, build, test, project structure * [Coding Standards](./coding-standards.md) — C# style, naming, architecture conventions * [Module Structure](./module-structure.md) — how to create a new Granit module * [Testing Guide](./testing-guide.md) — xUnit, Shouldly, NSubstitute, Bogus * [Definition of Done](./definition-of-done.md) — blocking checklist before any push * [Git Workflow](./git-workflow.md) — branches, commits, MR targets, releases ## License [Section titled “License”](#license) By contributing, you agree that your contributions will be licensed under the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). # Coding Standards > C# style rules, naming conventions, and architecture patterns for Granit contributors ## Naming conventions [Section titled “Naming conventions”](#naming-conventions) | Element | Convention | Example | | ------------------- | ------------------------------------ | --------------------------------------- | | Types | PascalCase, `sealed` by default | `sealed class AuditedEntityInterceptor` | | Interfaces | `I` + PascalCase | `ICurrentUserService` | | Methods | PascalCase, `Async` suffix for async | `EncryptAsync()` | | Properties | PascalCase | `CreatedAt`, `TenantId?` | | Private fields | `_camelCase` (underscore prefix) | `_currentUserService` | | Constants | PascalCase (not UPPER\_SNAKE) | `SectionName` | | Parameters / locals | camelCase | `string errorCode` | | Generics | `T` or `TPrefix` | `TModule`, `TEntity` | | Options classes | `Options` suffix, `sealed` | `sealed class VaultOptions` | | DI extensions | `Add*` / `Use*` | `AddGranitTiming()` | | Module classes | `Module` suffix | `GranitTimingModule` | | Enums | PascalCase values | `SequentialGuidType.AtEnd` | | Endpoint DTOs | `[Module][Concept][Suffix]` | `WorkflowTransitionRequest` | ## DTO naming rules [Section titled “DTO naming rules”](#dto-naming-rules) OpenAPI flattens C# namespaces — only the short type name appears in the schema. Two modules exposing an `AttachmentInfo` will cause a conflict. ### Prefix with module context [Section titled “Prefix with module context”](#prefix-with-module-context) Every public type used as an endpoint parameter or return value **must** carry a prefix identifying its module: | Wrong (too generic) | Correct (prefixed) | Module | | ------------------- | --------------------------- | ------------ | | `AttachmentInfo` | `TimelineAttachmentInfo` | Timeline | | `ColumnMapping` | `ImportColumnMapping` | DataExchange | | `TransitionRequest` | `WorkflowTransitionRequest` | Workflow | ### Required suffixes [Section titled “Required suffixes”](#required-suffixes) | Suffix | Role | Example | | ---------- | -------------------------------- | -------------------------- | | `Request` | Input body (POST/PUT) | `CreateSavedViewRequest` | | `Response` | Top-level return (GET, POST 201) | `UserNotificationResponse` | Warning The suffix `Dto` is **forbidden**. It conveys no information about the type’s role. Use `Request` or `Response` instead. ### Entity/API separation [Section titled “Entity/API separation”](#entityapi-separation) EF Core entities must **never** be returned directly by an endpoint. Create a `*Response` record that projects only the fields relevant to the consumer. ```csharp // Correct -- dedicated Response record public sealed record SavedViewResponse { public required Guid Id { get; init; } public required string Name { get; init; } public required string EntityType { get; init; } } // Wrong -- EF entity returned directly (leaks audit fields) group.MapGet("/", () => TypedResults.Ok(efEntities)); ``` **Exemptions**: shared cross-cutting types (`PagedResult`, `ProblemDetails`) do not need a module prefix. ## C# style rules [Section titled “C# style rules”](#c-style-rules) ### `var` usage (IDE0008) [Section titled “var usage (IDE0008)”](#var-usage-ide0008) Use `var` when the type is apparent on the right side; explicit type otherwise. ```csharp var stream = File.OpenRead("data.csv"); // type is apparent (FileStream) var users = new Dictionary(); // type is apparent (new) ImportResult result = _service.ImportAsync(data); // explicit -- type not obvious ``` ### Expression body (IDE0022) [Section titled “Expression body (IDE0022)”](#expression-body-ide0022) Use expression body (`=>`) for single-statement methods: ```csharp public IReadOnlyList GetModuleTypes() => [.. _modules.Select(m => m.ModuleType)]; ``` ### Braces are mandatory [Section titled “Braces are mandatory”](#braces-are-mandatory) Even for single-line blocks: ```csharp // Correct if (context is null) { return; } // Wrong if (context is null) return; ``` ### Other style rules [Section titled “Other style rules”](#other-style-rules) * **Classes `sealed` by default** — only leave unsealed when inheritance is explicitly intended * **File-scoped namespaces** — `namespace Granit.Vault.Services;` * **Collection expressions (C# 12+)** — `[]` for empty lists, `[.. enumerable]` for spread * **Pattern matching** — `is null`, `is not null` (never `== null`) * **Target-typed `new()`** — when the type is explicit on the left side ### Zero warnings [Section titled “Zero warnings”](#zero-warnings) The project must compile with zero warnings. Warnings are latent bugs. Fix them or suppress explicitly with `#pragma warning disable` plus a justification comment. ## File organization [Section titled “File organization”](#file-organization) ### `using` order [Section titled “using order”](#using-order) System, then Microsoft, then project/third-party (enforced by `.editorconfig`): ```csharp using System.Text; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Granit.Vault.Options; using VaultSharp; ``` ### File structure [Section titled “File structure”](#file-structure) 1. Using statements 2. Namespace (file-scoped) 3. XML documentation on the type 4. Type declaration 5. Private readonly fields 6. Constructor (or primary constructor) 7. Public properties 8. Public methods 9. Private methods 10. Nested types (last) **One type per file**, file name matches type name. ## XML documentation [Section titled “XML documentation”](#xml-documentation) * **Required** on all public types and members * `` brief (1 line), `` for detail * `` for interface implementations * `` and `` for public methods * GDPR/ISO 27001 context in `` when relevant ```csharp /// /// Exception representing a violated business rule. /// Maps to 400 Bad Request. /// /// /// Implements : the message is safe to /// expose to clients. /// public class BusinessException : Exception ``` ## Comments and TODOs [Section titled “Comments and TODOs”](#comments-and-todos) ### Comments — explain the “why”, not the “what” [Section titled “Comments — explain the “why”, not the “what””](#comments--explain-the-why-not-the-what) A comment has value only if it explains a non-obvious reason. Well-named code describes *what it does* on its own. ### TODOs — attribution and traceability required [Section titled “TODOs — attribution and traceability required”](#todos--attribution-and-traceability-required) Every `TODO` must include the **author** and a **GitHub issue number**: ```csharp // TODO(JDO): Refactor this once we migrate to .NET 11 (Issue #452) ``` A `TODO` without an issue is noise. Create the issue first, then reference it. ### Forbidden comments [Section titled “Forbidden comments”](#forbidden-comments) * **No commented-out code** — use Git for history * **No `// removed`** or `// unused` — delete dead code * **No separator banners** (`// ===== Section =====`) — use `#region` or extract a class ## Async patterns [Section titled “Async patterns”](#async-patterns) * **`Async` suffix** on all async methods * **`CancellationToken`** as the last parameter with `= default` * **`ConfigureAwait(false)`** in library code (NuGet packages) * **`.WaitAsync(cancellationToken)`** for APIs without native CT support ```csharp public async Task DecryptAsync( string keyName, string ciphertext, CancellationToken cancellationToken = default) { Secret result = await _vaultClient.V1.Secrets.Transit .DecryptAsync(keyName, requestOptions, mountPoint: _options.TransitMountPoint) .ConfigureAwait(false); // ... } ``` ## Time management [Section titled “Time management”](#time-management) **Never** use `DateTime.Now`, `DateTime.UtcNow`, `DateTimeOffset.Now`, or `DateTimeOffset.UtcNow`. Inject `TimeProvider` (native .NET 8+) or `IClock` (Granit.Timing): ```csharp // Correct DateTimeOffset now = clock.Now; // Wrong -- static call, not testable DateTimeOffset now = DateTimeOffset.UtcNow; ``` ## Logging [Section titled “Logging”](#logging) Use **`[LoggerMessage]` source-generated** logging — never string interpolation in log calls: ```csharp [LoggerMessage( Level = LogLevel.Debug, Message = "Data encrypted with Transit key {KeyName}")] private static partial void LogEncrypted(ILogger logger, string keyName); ``` Benefits: zero allocation when the log level is inactive, AOT-compatible, compile-time verified placeholders. ## Regex [Section titled “Regex”](#regex) Use **`[GeneratedRegex]`** — never `new Regex(..., RegexOptions.Compiled)`: ```csharp [GeneratedRegex(@"^[a-z0-9-]+$", RegexOptions.None, 100)] private static partial Regex SlugRegex(); ``` The third parameter is a **timeout in milliseconds** — mandatory for regex on user input. ## Guard clauses [Section titled “Guard clauses”](#guard-clauses) Use `ArgumentNullException.ThrowIfNull()` for programmer errors (internal null checks). For user-facing validation, throw domain exceptions (`ValidationException`, `BusinessException`, `EntityNotFoundException`): ```csharp // Programmer error -- hidden 500 in production ArgumentNullException.ThrowIfNull(service); // User validation -- displayed in the UI (422) if (string.IsNullOrWhiteSpace(email)) { throw new ValidationException(new Dictionary { ["Email"] = ["The Email field is required."] }); } ``` ## Error responses [Section titled “Error responses”](#error-responses) All error responses must use `TypedResults.Problem()` (RFC 7807), never `TypedResults.BadRequest()`: ```csharp // Correct -- structured ProblemDetails return TypedResults.Problem( detail: "Invalid webhook payload.", statusCode: StatusCodes.Status400BadRequest); // Wrong -- string body, no structured error return TypedResults.BadRequest("Invalid webhook payload."); ``` The handler return type must reflect `ProblemHttpResult`: ```csharp private static Task> HandleWebhookAsync(...) ``` ## Endpoint conventions [Section titled “Endpoint conventions”](#endpoint-conventions) * **Minimal API only** — no MVC controllers * Handlers must be **named static methods** (no inline lambdas) * Every endpoint requires `.WithName()`, `.WithSummary()`, and `.WithTags()` * Use `TypedResults` (not `Results`) for correct OpenAPI schema inference * No anonymous return types — create a typed record ## Banned APIs [Section titled “Banned APIs”](#banned-apis) The following APIs are banned at compile time via `BannedSymbols.txt` (`Microsoft.CodeAnalysis.BannedApiAnalyzers`): | Banned API | Alternative | Reason | | ------------------------------------ | ----------------------------- | ------------------------- | | `new HttpClient()` | `IHttpClientFactory` | Socket exhaustion | | `new Regex(...)` | `[GeneratedRegex]` | AOT, performance | | `Thread.Sleep()` | `Task.Delay()` | Blocks thread pool | | `Task.Result` / `Task.Wait()` | `await` | Sync-over-async deadlock | | `GC.Collect()` | — | Forbidden in library code | | `Console.Write/WriteLine` | `ILogger` + `[LoggerMessage]` | Structured observability | | `Environment.GetEnvironmentVariable` | `IConfiguration` | Configuration injection | # Definition of Done > Blocking checklist that must pass before any push or merge request The Definition of Done is **blocking**. No push or merge request creation may happen until all checks are satisfied. ## Required checks — backend (.NET) [Section titled “Required checks — backend (.NET)”](#required-checks--backend-net) ### 1. Tests [Section titled “1. Tests”](#1-tests) Every modified package must have its `*.Tests` project updated. All tests must pass with zero failures: ```bash dotnet test ``` ### 2. Documentation [Section titled “2. Documentation”](#2-documentation) Any change to a public API, new feature, or behavior modification must be reflected in the documentation. ### 3. Format [Section titled “3. Format”](#3-format) Code formatting must pass with zero issues: ```bash dotnet format --verify-no-changes ``` ### 4. Markdownlint [Section titled “4. Markdownlint”](#4-markdownlint) Every modified `.md` file must pass markdownlint: ```bash npx markdownlint-cli2 "path/to/file.md" ``` ### 5. Third-party notices [Section titled “5. Third-party notices”](#5-third-party-notices) Any dependency added, removed, or updated must be reflected in `THIRD-PARTY-NOTICES.md`: * Add/modify/remove the package entry with its name, version, license (SPDX identifier), and copyright holder * Update the summary table at the top if the license count changes * Update the `Last updated` date Warning Report immediately any dependency under a **non-permissive license** (GPL, LGPL, AGPL, SSPL, or commercial restriction). ISO 27001/commercial context requires legal review before integration. ## Required checks — frontend (TypeScript / React) [Section titled “Required checks — frontend (TypeScript / React)”](#required-checks--frontend-typescript--react) If you are also modifying frontend code: 1. `pnpm test` — zero failures 2. `pnpm lint` — ESLint `--max-warnings 0` passes 3. `pnpm exec tsc --noEmit` — compiles without error 4. `npx prettier --check "src/**/*.{ts,tsx,css}"` — passes 5. `npx markdownlint-cli2 "path/to/file.md"` — passes 6. Every new visible component has a Storybook story 7. `THIRD-PARTY-NOTICES.md` updated if dependencies changed ## Quick reference script [Section titled “Quick reference script”](#quick-reference-script) Run all backend checks in sequence: ```bash dotnet build && \ dotnet test && \ dotnet format --verify-no-changes && \ npx markdownlint-cli2 "docs/**/*.md" ``` ## Refusal rule [Section titled “Refusal rule”](#refusal-rule) If a push is requested without the DoD being satisfied, the missing checks must be identified and resolved first. The DoD can only be overridden by explicit, per-item acknowledgement. ## Security checks [Section titled “Security checks”](#security-checks) In addition to the DoD, verify before every push: * No hardcoded secrets or credentials in the code * No PII logged in plain text * Sensitive data encrypted at rest and in transit * Audit trail maintained for sensitive operations These are not optional — they are compliance requirements (ISO 27001, GDPR). # Development Setup > Prerequisites, build instructions, and project structure for Granit contributors ## Prerequisites [Section titled “Prerequisites”](#prerequisites) | Tool | Version | Check | | -------- | --------------------------- | ------------------ | | .NET SDK | **10.0.x** | `dotnet --version` | | Git | 2.40+ with SSH access | `git --version` | | Node.js | 22+ (for markdownlint) | `node --version` | | Docker | 24+ (for integration tests) | `docker --version` | ### Recommended IDEs [Section titled “Recommended IDEs”](#recommended-ides) * **JetBrains Rider** (recommended) — full support for Roslyn analyzers, `.editorconfig`, and solution-wide analysis * **Visual Studio 2022** (17.14+) — ensure the .NET 10 workload is installed * **VS Code** — with the C# Dev Kit extension ## Clone and build [Section titled “Clone and build”](#clone-and-build) ```bash git clone git@github.com:granit-fx/granit-dotnet.git cd granit-dotnet # Build the entire solution dotnet build # Run all tests dotnet test # Run tests for a specific package dotnet test tests/Granit.Security.Tests # Verify code formatting dotnet format --verify-no-changes # Validate markdown files npx markdownlint-cli2 "docs/**/*.md" ``` ## NuGet packaging [Section titled “NuGet packaging”](#nuget-packaging) ```bash # Pack all packages locally dotnet pack -c Release -o ./nupkgs ``` All package versions are managed centrally in `Directory.Packages.props` (Central Package Management). ## Project structure overview [Section titled “Project structure overview”](#project-structure-overview) ```text granit-dotnet/ ├── src/ Source packages (one project = one NuGet package) │ ├── Granit.Core/ │ ├── Granit.Timing/ │ ├── Granit.Vault/ │ └── ... ├── tests/ Test projects (mirror of src/) │ ├── Granit.Core.Tests/ │ ├── Granit.Timing.Tests/ │ ├── Granit.ArchitectureTests/ │ └── ... ├── docs-site/ Starlight documentation site ├── Directory.Build.props Shared build properties (nullable, warnings-as-errors) ├── Directory.Packages.props Central Package Management ├── BannedSymbols.txt Banned API list (enforced at compile time) └── Granit.sln Solution file ``` ## Build properties [Section titled “Build properties”](#build-properties) The following settings are enforced globally via `Directory.Build.props`: * **Nullable reference types**: enabled (`enable`) * **Warnings as errors**: enabled — the project must compile with zero warnings * **Target framework**: `net10.0` * **Language version**: C# 14 * **ImplicitUsings**: enabled ## Docs site [Section titled “Docs site”](#docs-site) The documentation site uses [Astro Starlight](https://starlight.astro.build/). To run it locally: ```bash cd docs-site pnpm install pnpm dev ``` ## Next steps [Section titled “Next steps”](#next-steps) * Read the [Coding Standards](./coding-standards/) before writing code * Check the [Module Structure](./module-structure/) if you are creating a new package * Review the [Definition of Done](./definition-of-done/) before pushing # Git Workflow > Branching strategy, commit conventions, MR targets, and release process for Granit ## Branching (GitFlow) [Section titled “Branching (GitFlow)”](#branching-gitflow) | Branch type | Convention | MR target | | ----------- | ------------------------- | ------------------ | | `feature/*` | New features | `develop` | | `fix/*` | Bug fixes | `develop` | | `hotfix/*` | Urgent production fixes | `main` + `develop` | | `release/*` | Pre-release stabilization | `main` + `develop` | Additional branch prefixes: `docs/`, `refactor/`, `chore/`, `test/`, `perf/` — all target `develop`. Warning **Direct push to `main` is forbidden.** All changes go through a merge request with at least 1 approval. ## MR targets — strict rules [Section titled “MR targets — strict rules”](#mr-targets--strict-rules) | Branch type | Default target | Exception | | ----------- | ------------------ | --------------------------------------------- | | `feature/*` | `develop` | Only if explicitly requested to target `main` | | `fix/*` | `develop` | Only if explicitly requested to target `main` | | `hotfix/*` | `main` + `develop` | Always both | | `release/*` | `main` + `develop` | Always both | Never target `main` for a `feature/*` or `fix/*` branch unless explicitly requested. When in doubt, ask before creating the MR. ## Conventional Commits [Section titled “Conventional Commits”](#conventional-commits) All commits use [Conventional Commits](https://www.conventionalcommits.org/) in **English**: ```text feat(vault): add Transit AES-256 encryption fix(persistence): handle audit interceptor on detached entities docs(guide): create coding conventions guide chore(ci): update GitHub Actions pipeline refactor(notifications): extract channel dispatcher test(security): add JWT rotation tests perf(caching): reduce Redis roundtrips for hybrid cache ``` ### Commit types [Section titled “Commit types”](#commit-types) | Type | When to use | | ---------- | ------------------------------------------------------- | | `feat` | New feature or capability | | `fix` | Bug fix | | `docs` | Documentation changes only | | `chore` | Build, CI, tooling, dependencies | | `refactor` | Code change that neither fixes a bug nor adds a feature | | `test` | Adding or updating tests | | `perf` | Performance improvement | ### Scope [Section titled “Scope”](#scope) The scope in parentheses identifies the affected package or area: * Package name without the `Granit.` prefix: `(vault)`, `(persistence)`, `(notifications)` * Cross-cutting scopes: `(ci)`, `(build)`, `(deps)` * Omit the scope for changes that span many packages ### Breaking changes [Section titled “Breaking changes”](#breaking-changes) Add `!` after the type/scope and a `BREAKING CHANGE:` footer: ```text feat(querying)!: change filter syntax from bracket to dot notation BREAKING CHANGE: filter parameters now use `filter.field.operator=value` instead of `filter[field][operator]=value`. Update all API clients. ``` ## Third-party notices [Section titled “Third-party notices”](#third-party-notices) When adding, removing, or upgrading a dependency: 1. Update `THIRD-PARTY-NOTICES.md` with the package name, version, license (SPDX), and copyright holder 2. Update the summary table if license counts changed 3. Update the `Last updated` date 4. Flag any non-permissive license (GPL, AGPL, SSPL) for legal review Never add or update a dependency without modifying `THIRD-PARTY-NOTICES.md`. ## Code review checklist [Section titled “Code review checklist”](#code-review-checklist) A maintainer will review your MR against this checklist: * [ ] No hardcoded secrets * [ ] Tests pass (`dotnet test`) * [ ] Build succeeds (`dotnet build`) * [ ] Format verified (`dotnet format --verify-no-changes`) * [ ] No PII in logs * [ ] `THIRD-PARTY-NOTICES.md` updated if dependencies changed * [ ] Documentation updated if applicable ## Releases [Section titled “Releases”](#releases) * Semantic tags on `main`: `vMAJOR.MINOR.PATCH` * `release/*` branches for stabilization before the tag * 1 approval minimum for merging to `main` ### Version semantics [Section titled “Version semantics”](#version-semantics) | Change | Version bump | Example | | -------------------------------- | ------------ | ------- | | Breaking API change | MAJOR | v2.0.0 | | New feature, backward compatible | MINOR | v1.3.0 | | Bug fix, backward compatible | PATCH | v1.3.1 | ## Language rules [Section titled “Language rules”](#language-rules) | Content | Language | | --------------------------------------------- | ----------- | | Code (identifiers, XML docs, comments) | English | | Commits (Conventional Commits) | English | | Documentation (`docs/**/*.md`) | English | | GitHub issues (title, description) | French | | Localization files (`Localization/**/*.json`) | 17 cultures | Code must never contain French diacritics. French diacritics (é, è, ê, à, â, ù, û, ô, î, ï, ç, œ) are required in French-language content (issues). # Module Structure > How to create a new Granit module -- project layout, DbContext checklist, NuGet packaging Every Granit package follows a predictable directory structure. A developer opening `Granit.Identity` should find the same layout as in `Granit.Workflow` or `Granit.DataExchange`. ## Business package blueprint [Section titled “Business package blueprint”](#business-package-blueprint) ```text Granit.Example/ ├── Domain/ Entities, aggregates, Value Objects, domain enums ├── Events/ Wolverine messages (events, commands) ├── Exceptions/ Business exceptions (inherit BusinessException, etc.) ├── Extensions/ Extension methods (DI, builders, etc.) ├── Handlers/ Wolverine handlers (event/command consumers) ├── Internal/ Internal implementations (services, stores, etc.) ├── Localization/ JSON translation files (17 cultures) ├── Options/ IOptions configuration classes ├── GranitExampleModule.cs Module class (required at root) ├── IExampleReader.cs Public interfaces (at module root) ├── IExampleWriter.cs ├── Granit.Example.csproj └── README.md ``` ## Endpoints package blueprint [Section titled “Endpoints package blueprint”](#endpoints-package-blueprint) ```text Granit.Example.Endpoints/ ├── Dtos/ Request/Response records (API contracts) ├── Endpoints/ Minimal API classes (MapGet, MapPost, etc.) ├── Extensions/ EndpointRouteBuilderExtensions ├── Internal/ Internal implementations ├── Localization/ JSON translation files ├── Options/ Endpoint configuration ├── Permissions/ PermissionDefinitionProvider ├── Validators/ FluentValidation validators ├── GranitExampleEndpointsModule.cs ├── Granit.Example.Endpoints.csproj └── README.md ``` ## EntityFrameworkCore package blueprint [Section titled “EntityFrameworkCore package blueprint”](#entityframeworkcore-package-blueprint) ```text Granit.Example.EntityFrameworkCore/ ├── Configurations/ Fluent API (IEntityTypeConfiguration) ├── Entities/ EF-only entities (not in the business package) ├── Extensions/ ServiceCollectionExtensions (AddDbContextFactory, etc.) ├── Internal/ DbContext, EfCoreStore implementations ├── GranitExampleEntityFrameworkCoreModule.cs ├── IExampleDbContext.cs Host interface (optional, for shared DbContexts) ├── Granit.Example.EntityFrameworkCore.csproj └── README.md ``` ## Standard directory reference [Section titled “Standard directory reference”](#standard-directory-reference) ### Universal directories (all packages) [Section titled “Universal directories (all packages)”](#universal-directories-all-packages) | Directory | Content | Expected visibility | | --------------- | ------------------------------------------------------------------------------- | ------------------- | | `Extensions/` | Extension methods (`IServiceCollection`, `IApplicationBuilder`, `ModelBuilder`) | `public static` | | `Internal/` | Concrete implementations, services, stores, handlers, DbContext | `internal sealed` | | `Options/` | Configuration classes bound to `IOptions` / `IOptionsMonitor` | `public` | | `Localization/` | JSON translation files (`en.json`, `fr.json`, etc.) | — | ### Domain directories (business packages) [Section titled “Domain directories (business packages)”](#domain-directories-business-packages) | Directory | Content | Expected visibility | | ------------- | -------------------------------------------------------------------- | ---------------------- | | `Domain/` | Rich entities, aggregates, Value Objects, domain enums | `public` | | `Events/` | Wolverine messages (integration events, commands) | `public sealed record` | | `Exceptions/` | Business exceptions (`BusinessException`, `NotFoundException`, etc.) | `public sealed` | | `Handlers/` | Wolverine handlers for events/commands | `internal sealed` | ### API directories (\*.Endpoints packages) [Section titled “API directories (\*.Endpoints packages)”](#api-directories-endpoints-packages) | Directory | Content | Expected visibility | | -------------- | ------------------------------------------------------- | ---------------------- | | `Dtos/` | Request (`*Request`) and response (`*Response`) records | `public sealed record` | | `Endpoints/` | Minimal API classes declaring routes | `internal sealed` | | `Permissions/` | `*PermissionDefinitionProvider` | `internal sealed` | | `Validators/` | FluentValidation validators (`*Validator`) | `internal sealed` | ### Persistence directories (\*.EntityFrameworkCore packages) [Section titled “Persistence directories (\*.EntityFrameworkCore packages)”](#persistence-directories-entityframeworkcore-packages) | Directory | Content | Expected visibility | | ----------------- | ---------------------------------------------------- | ------------------- | | `Configurations/` | `IEntityTypeConfiguration` (Fluent API) | `internal sealed` | | `Entities/` | EF-only entities not present in the business package | `public sealed` | ## Forbidden directories [Section titled “Forbidden directories”](#forbidden-directories) | Name | Why forbidden | Use instead | | ------------------------------- | ------------------------------------------------------------ | ---------------------------------------------------- | | `Helpers/`, `Utils/`, `Common/` | No semantics — becomes a dumping ground | `Internal/` or a thematic directory | | `Services/` | Too generic — says nothing about responsibility | `Internal/` for implementations, root for interfaces | | `Abstractions/` | Granit does not separate interfaces into a dedicated package | Module root | | `Models/` | Ambiguous between DTO, entity, and ViewModel | `Domain/` for entities, `Dtos/` for DTOs | | `DbContext/` (directory) | Legacy pattern | `Internal/` | | `Stores/` (directory) | Stores are internal implementations | `Internal/` | ## Placement rules [Section titled “Placement rules”](#placement-rules) | File type | Location | Example | | -------------------------------- | ----------------- | ------------------------------------------------------ | | Public interface | **Module root** | `IBlobStorage.cs` | | Public record (service contract) | **Module root** | `BlobUploadRequest.cs` | | Module class | **Module root** | `GranitBlobStorageModule.cs` | | Domain entity | **`Domain/`** | `Domain/ExportJob.cs` | | Business exception | **`Exceptions/`** | `Exceptions/BlobNotFoundException.cs` | | Wolverine event | **`Events/`** | `Events/BlobDeleted.cs` | | Internal implementation | **`Internal/`** | `Internal/DefaultBlobStorage.cs` | | Options class | **`Options/`** | `Options/BlobStorageOptions.cs` | | DI extension | **`Extensions/`** | `Extensions/BlobStorageServiceCollectionExtensions.cs` | Warning Any `internal` type must reside in `Internal/` or in a thematic subdirectory (`Validators/`, `Endpoints/`, `Configurations/`, etc.). An `internal` type at the module root will fail architecture tests. ### Maximum depth [Section titled “Maximum depth”](#maximum-depth) **2 levels maximum** under the module. The IDE handles search — no need for deep nesting. ```text OK Granit.Foo/Internal/MyStore.cs OK Granit.Foo/Domain/MyEntity.cs BAD Granit.Foo/Domain/SubDomain/Values/MyValue.cs (too deep) ``` Exception: complex multi-feature modules (e.g., `Granit.DataExchange`) may use feature-level directories at the first level, then standard directories inside. ## Namespace synchronization [Section titled “Namespace synchronization”](#namespace-synchronization) The namespace must **always** match the directory structure: ```text src/Granit.Foo/Internal/MyStore.cs -> namespace Granit.Foo.Internal; src/Granit.Foo/Domain/MyEntity.cs -> namespace Granit.Foo.Domain; src/Granit.Foo/IMyService.cs -> namespace Granit.Foo; ``` This is enforced by the architecture test `Namespace_should_match_folder_structure_in_src`. ## Isolated DbContext checklist [Section titled “Isolated DbContext checklist”](#isolated-dbcontext-checklist) Every `*.EntityFrameworkCore` package that owns an isolated `DbContext` **must** follow this checklist with no exceptions: 1. **`` to `Granit.Persistence`** in the `.csproj` 2. **Constructor injection** of `ICurrentTenant?` and `IDataFilter?` (both optional, default `null`) 3. **Call `modelBuilder.ApplyGranitConventions(currentTenant, dataFilter)`** at the end of `OnModelCreating` 4. **Interceptor wiring** in the extension method: use the `(sp, options)` overload of `AddDbContextFactory` with `ServiceLifetime.Scoped` and resolve `AuditedEntityInterceptor` / `SoftDeleteInterceptor` from the service provider 5. **`[DependsOn(typeof(GranitPersistenceModule))]`** on the module class 6. **No manual `HasQueryFilter`** in entity configurations — `ApplyGranitConventions` handles all standard filters centrally 7. **`IMultiTenant` entities** use `Guid? TenantId` (never `string`) ## Validator registration [Section titled “Validator registration”](#validator-registration) Modules decorated with `[assembly: WolverineHandlerModule]` get automatic validator discovery via `AddGranitWolverine()`. Modules **without** Wolverine handlers must call `AddGranitValidatorsFromAssemblyContaining()` manually. Without registration, `FluentValidationEndpointFilter` silently skips validation. ## New module checklist [Section titled “New module checklist”](#new-module-checklist) 1. Create the project: `dotnet new classlib -n Granit.Example` 2. Add `GranitExampleModule.cs` at the root 3. Create `Extensions/ExampleServiceCollectionExtensions.cs` with `AddGranitExample()` 4. Create `README.md` 5. Create the mirror test project: `tests/Granit.Example.Tests/` 6. Add directories as needed from the blueprints above 7. Verify with `dotnet test tests/Granit.ArchitectureTests` that the structure is compliant # Testing Guide > Testing conventions for Granit -- xUnit, Shouldly, NSubstitute, Bogus, architecture tests Every Granit package has a matching `*.Tests` project under `tests/`. This is enforced by the architecture test `Every_src_package_should_have_a_test_project`. ## Stack [Section titled “Stack”](#stack) | Library | Role | | -------------------- | ------------------------------------------------------------------------ | | **xUnit** | Test framework | | **Shouldly** | Assertions | | **NSubstitute** | Mocking | | **Bogus** | Test data generation | | **FakeTimeProvider** | Deterministic time control (`Microsoft.Extensions.TimeProvider.Testing`) | ## Naming [Section titled “Naming”](#naming) Test classes follow the pattern `{ClassUnderTest}Tests`: | Source class | Test class | | -------------------------- | ------------------------------- | | `Clock` | `ClockTests` | | `AuditedEntityInterceptor` | `AuditedEntityInterceptorTests` | | `CurrentUserService` | `CurrentUserServiceTests` | Test methods follow `Method_Scenario_ExpectedBehavior`: ```csharp [Fact] public void Now_ReturnsUtcFromTimeProvider() { ... } [Fact] public async Task SaveChangesAsync_OnAdd_SetsCreatedFields() { ... } [Fact] public void UserId_WithAuthenticatedUser_ReturnsSubClaim() { ... } ``` ## AAA pattern [Section titled “AAA pattern”](#aaa-pattern) All tests follow Arrange-Act-Assert strictly: ```csharp [Fact] public void Normalize_ConvertsLocalOffsetToUtc() { // Arrange - DateTimeOffset with +02:00 offset (Europe/Brussels in summer) var localTime = new DateTimeOffset(2026, 6, 15, 14, 30, 0, TimeSpan.FromHours(2)); // Act var normalized = _clock.Normalize(localTime); // Assert - Must be converted to UTC (+00:00), same instant normalized.Offset.ShouldBe(TimeSpan.Zero); normalized.ShouldBe(new DateTimeOffset(2026, 6, 15, 12, 30, 0, TimeSpan.Zero)); } ``` ## Test class structure [Section titled “Test class structure”](#test-class-structure) Every test class is `public sealed class`. No inheritance hierarchies, no shared base classes. Dependencies are initialized in the constructor: ```csharp public sealed class ClockTests { private readonly FakeTimeProvider _fakeTimeProvider; private readonly ICurrentTimezoneProvider _timezoneProvider; private readonly Clock _clock; public ClockTests() { _fakeTimeProvider = new FakeTimeProvider(); _timezoneProvider = Substitute.For(); _clock = new Clock(_fakeTimeProvider, _timezoneProvider); } // ... tests } ``` ## Descriptive file headers [Section titled “Descriptive file headers”](#descriptive-file-headers) Each test file starts with a header describing what is tested and the approach: ```csharp // ============================================================================= // Tests - AuditedEntityInterceptor // ============================================================================= // Verifies that ISO 27001 audit fields are correctly populated // during entity creation and modification. // // Approach: register the interceptor in the DbContext and call // SaveChangesAsync directly, which triggers the interceptor naturally. // IClock is mocked for exact assertions. // ============================================================================= ``` ## CancellationToken in tests [Section titled “CancellationToken in tests”](#cancellationtoken-in-tests) Warning Always use `TestContext.Current.CancellationToken` in async tests. Never use `CancellationToken.None`. This ensures tests are cancelled properly when the test runner times out or is stopped. ```csharp [Fact] public async Task GetAsync_ReturnsEntity() { // Arrange var store = CreateStore(); // Act var result = await store.GetAsync(entityId, TestContext.Current.CancellationToken); // Assert result.ShouldNotBeNull(); } ``` ## Assertions with Shouldly [Section titled “Assertions with Shouldly”](#assertions-with-shouldly) Common patterns: ```csharp // Exact value entity.CreatedAt.ShouldBe(fixedNow); // Not empty guid.ShouldNotBe(Guid.Empty); // Collection sut.Roles.ShouldBe(new[] { "admin", "practitioner" }); // Boolean sut.IsAuthenticated.ShouldBeTrue(); // Null sut.UserId.ShouldBeNull(); // Count guids.Count().ShouldBe(10_000, "all GUIDs should be unique"); // Contextual message for non-obvious assertions now.Offset.ShouldBe(TimeSpan.Zero, "Clock must always return UTC (ISO 27001 compliance)"); ``` ### Exception assertions [Section titled “Exception assertions”](#exception-assertions) ```csharp // Sync InvalidOperationException ex = Should.Throw(act); ex.Message.ShouldContain("expected text"); // Async InvalidOperationException ex = await Should.ThrowAsync(act); ex.Message.ShouldContain("expected text"); ``` ## Mocking with NSubstitute [Section titled “Mocking with NSubstitute”](#mocking-with-nsubstitute) ### Interface mocking [Section titled “Interface mocking”](#interface-mocking) ```csharp var clock = Substitute.For(); var fixedNow = new DateTimeOffset(2026, 6, 15, 10, 30, 0, TimeSpan.Zero); clock.Now.Returns(fixedNow); ``` ### IOptions mocking [Section titled “IOptions mocking”](#ioptions-mocking) ```csharp // Using NSubstitute var options = Substitute.For>(); options.Value.Returns(new GuidGeneratorOptions { DefaultSequentialGuidType = SequentialGuidType.SequentialAsString }); // Using the Options helper (simpler) var options = Microsoft.Extensions.Options.Options.Create(new VaultOptions { TransitMountPoint = "transit" }); ``` ### Mocking strategy [Section titled “Mocking strategy”](#mocking-strategy) | Dependency | Strategy | Reason | | --------------------- | ---------------------------------------- | -------------------------------------------- | | `IClock` | Mock | Deterministic time assertions | | `IGuidGenerator` | Mock | Control generated identifiers | | `ICurrentUserService` | Mock | Simulate different user contexts | | `TimeProvider` | `FakeTimeProvider` (real implementation) | Provided by Microsoft for time tests | | `DbContext` | In-memory (real implementation) | Test EF Core interceptors with real pipeline | | `IVaultClient` | Mock | No Vault in unit tests | ## Deterministic time [Section titled “Deterministic time”](#deterministic-time) Use `FakeTimeProvider` for time-dependent tests: ```csharp FakeTimeProvider fakeTimeProvider = new(); ICurrentTimezoneProvider timezoneProvider = Substitute.For(); Clock clock = new(fakeTimeProvider, timezoneProvider); // Pin time to a fixed instant DateTimeOffset fixedTime = new(2026, 6, 15, 10, 30, 0, TimeSpan.Zero); fakeTimeProvider.SetUtcNow(fixedTime); clock.Now.ShouldBe(fixedTime); ``` ## EF Core integration tests [Section titled “EF Core integration tests”](#ef-core-integration-tests) Integration tests use `UseInMemoryDatabase` with a unique name per test for isolation: ```csharp private TestDbContext CreateContext() { var interceptor = new AuditedEntityInterceptor( _currentUserService, _clock, _guidGenerator); var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .AddInterceptors(interceptor) .Options; return new TestDbContext(options); } ``` Define test entities and DbContexts as internal types within the test class: ```csharp private sealed class TestEntity : AuditedEntity { public string Name { get; set; } = string.Empty; } private sealed class TestDbContext : DbContext { public TestDbContext(DbContextOptions options) : base(options) { } public DbSet TestEntities => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity() .Property(e => e.Id).ValueGeneratedNever(); } } ``` ## Architecture tests [Section titled “Architecture tests”](#architecture-tests) `Granit.ArchitectureTests` enforces structural rules across all packages using ArchUnitNET (assembly analysis) and source code scanning (regex). These tests cover: * **Class design** — sealed DbContexts, no MVC controllers, sealed Options, no public types in `Internal` namespaces * **Layer dependencies** — Core/Timing/Guids must not depend on EF Core, Endpoints must not depend on EF Core, no `IQueryable` outside persistence * **Naming conventions** — interfaces start with `I`, Reader/Writer suffixes, no `Dto` suffix in endpoints * **Module conventions** — modules must be sealed, names must start with `Granit` and end with `Module` * **File organization** — modules at root, extensions in `Extensions/`, DTOs in `Dtos/`, etc. * **Anti-patterns** — no `async void`, no `throw ex;`, namespace matches directory structure Run them with: ```bash dotnet test tests/Granit.ArchitectureTests ``` Warning Never modify `Granit.ArchitectureTests` or `Granit.ArchitectureTests.Abstractions` to work around a test failure. These are architectural guardrails. If a rule produces a false positive, mark it as won’t fix. ## Running tests [Section titled “Running tests”](#running-tests) ```bash # All tests dotnet test # Specific package dotnet test tests/Granit.Security.Tests # Architecture tests only dotnet test tests/Granit.ArchitectureTests # With detailed output dotnet test --logger "console;verbosity=detailed" ``` # Getting Started > Build a Task Management API with Granit in 5 progressive steps This tutorial walks you through building a complete REST API with Granit — from an empty project to a production-ready service with persistence, authentication, and observability. ## Prerequisites [Section titled “Prerequisites”](#prerequisites) * **.NET 10 SDK** (or later) * **Docker** (for PostgreSQL) * A code editor (Rider, VS Code, or Visual Studio) ## What you will build [Section titled “What you will build”](#what-you-will-build) A Task Management API with: * CRUD endpoints using Minimal API * EF Core persistence with automatic audit trails * JWT authentication via Keycloak * OpenTelemetry observability (logs, traces, metrics) Each step builds on the previous one. By the end, you will have a production-ready service that follows Granit conventions. ## Steps [Section titled “Steps”](#steps) 1. [Your First API](./your-first-api/) — Create a module, wire it into `Program.cs`, define a domain model, and expose your first endpoint. 2. [Adding Persistence](./adding-persistence/) — Add EF Core with PostgreSQL, automatic audit fields, and soft delete. 3. [Adding Authentication](./adding-authentication/) — Secure endpoints with JWT Bearer tokens and Keycloak. 4. [Project Templates](./project-templates/) — Use `dotnet new` templates to scaffold new Granit projects. 5. [Next Steps](./next-steps/) — Explore advanced modules: notifications, workflows, blob storage, and more. # Adding Authentication > Protect your API with JWT Bearer authentication and provider-specific claims transformation In the previous step, you added persistence with EF Core and automatic audit trails. The audit fields (`CreatedBy`, `ModifiedBy`) are still empty because the API does not know who is calling it. In this step, you will add JWT Bearer authentication with Keycloak claims transformation so that every request carries a verified identity. ## 1. Add the authentication packages [Section titled “1. Add the authentication packages”](#1-add-the-authentication-packages) ```bash dotnet add package Granit.Authentication.JwtBearer dotnet add package Granit.Authentication.Keycloak ``` `Granit.Authentication.JwtBearer` configures JWT Bearer validation from your `appsettings.json`. `Granit.Authentication.Keycloak` adds a claims transformation that extracts Keycloak roles from `realm_access` and `resource_access` into standard .NET claims. ## 2. Update the module dependencies [Section titled “2. Update the module dependencies”](#2-update-the-module-dependencies) Open `TaskManagementModule.cs` and add the Keycloak authentication dependency: ```csharp using Granit.Core.Modularity; using Granit.Timing; using Granit.Persistence; using Granit.Authentication.Keycloak; namespace TaskManagement.Api; [DependsOn(typeof(GranitTimingModule))] [DependsOn(typeof(GranitPersistenceModule))] [DependsOn(typeof(GranitAuthenticationKeycloakModule))] public sealed class TaskManagementModule : GranitModule { // ConfigureServices stays the same... } ``` ## 3. Add configuration [Section titled “3. Add configuration”](#3-add-configuration) Add a `JwtBearer` section to `appsettings.Development.json`: * Keycloak ```json { "JwtBearer": { "Authority": "http://localhost:8080/realms/task-management", "Audience": "task-management-api", "RequireHttpsMetadata": false } } ``` * Entra ID ```json { "JwtBearer": { "Authority": "https://login.microsoftonline.com/{tenant-id}/v2.0", "Audience": "api://{client-id}", "RequireHttpsMetadata": true } } ``` * AWS Cognito ```json { "JwtBearer": { "Authority": "https://cognito-idp.{region}.amazonaws.com/{userPoolId}", "Audience": "{clientId}", "RequireHttpsMetadata": true }, "Cognito": { "UserPoolId": "{userPoolId}", "ClientId": "{clientId}", "Region": "{region}" } } ``` ## 4. Add middleware [Section titled “4. Add middleware”](#4-add-middleware) Open `Program.cs` and add the authentication and authorization middleware between `UseGranit()` and your route mappings: ```csharp var app = builder.Build(); app.UseGranit(); app.UseAuthentication(); app.UseAuthorization(); app.MapGet("/", (IClock clock) => new { Message = "Task Management API", CurrentTime = clock.Now }); // ... your other endpoints app.Run(); ``` Middleware order matters. `UseAuthentication()` must come before `UseAuthorization()`, and both must come after `UseGranit()`. ## 5. Protect your endpoints [Section titled “5. Protect your endpoints”](#5-protect-your-endpoints) Add `.RequireAuthorization()` to your route group so that all task endpoints require a valid JWT: ```csharp var tasks = app.MapGroup("/api/tasks") .RequireAuthorization(); tasks.MapGet("/", async (TaskManagementDbContext db) => { var items = await db.Tasks.ToListAsync(); return Results.Ok(items); }); // ... other endpoints in the group inherit the authorization requirement ``` Any request without a valid `Authorization: Bearer ` header now receives a `401 Unauthorized` response. ## 6. What happens automatically [Section titled “6. What happens automatically”](#6-what-happens-automatically) With a single `[DependsOn]` attribute, the framework wires up several behaviors behind the scenes: * **JWT validation** — `GranitJwtBearerModule` reads the `JwtBearer` configuration section and sets up token validation parameters (issuer, audience, signing keys). * **Claims transformation** — `KeycloakClaimsTransformation` extracts roles from the Keycloak-specific `realm_access` and `resource_access` JWT claims and maps them to standard .NET role claims. * **Current user** — `ICurrentUserService` is populated from the JWT claims on every authenticated request. Inject it anywhere you need the caller’s identity. * **Audit trail** — `AuditedEntityInterceptor` reads `ICurrentUserService` to fill `CreatedBy` and `ModifiedBy` on every entity save. No extra code required. ## 7. Test with a token [Section titled “7. Test with a token”](#7-test-with-a-token) Obtain a token from your Keycloak instance (or any OIDC provider) and pass it in the `Authorization` header: ```bash # Get a token from Keycloak using the password grant (development only) TOKEN=$(curl -s -X POST \ "http://localhost:8080/realms/task-management/protocol/openid-connect/token" \ -d "client_id=task-management-api" \ -d "username=testuser" \ -d "password=testpassword" \ -d "grant_type=password" | jq -r '.access_token') # Call the protected endpoint curl -H "Authorization: Bearer $TOKEN" http://localhost:5001/api/tasks ``` A valid token returns your task list. An expired or missing token returns `401 Unauthorized`. ## 8. The audit trail now works [Section titled “8. The audit trail now works”](#8-the-audit-trail-now-works) Create a task with a valid token and inspect the database row. The `CreatedBy` and `ModifiedBy` columns now contain the authenticated user’s ID extracted from the JWT `sub` claim. Every task created or modified records who did it — zero extra code, just a `[DependsOn]`. ## 9. Observability bonus [Section titled “9. Observability bonus”](#9-observability-bonus) Once authentication is in place, consider adding `Granit.Observability` to get structured logging and distributed tracing. The setup follows the same pattern: ```bash dotnet add package Granit.Observability ``` Add `[DependsOn(typeof(GranitObservabilityModule))]` to your module and configure the OTLP endpoint in `appsettings.json`. Authentication events, database queries, and HTTP requests are traced automatically. See the [Observability reference](/reference/modules/observability/) for the full configuration guide. ## Next step [Section titled “Next step”](#next-step) You now have a modular API with persistence, audit trails, and authentication. Instead of building this from scratch each time, Granit provides project templates that scaffold the full stack in one command. [Project Templates](/getting-started/project-templates/) # Adding Persistence > Add EF Core with automatic audit trails, soft delete interceptors, and PostgreSQL In [Your First API](/getting-started/your-first-api/), you created a module with an in-memory list. That works for a demo, but real applications need a database. In this tutorial you will wire up EF Core with PostgreSQL and let Granit handle audit fields, soft delete, and sequential GUIDs automatically. ## Install packages [Section titled “Install packages”](#install-packages) Add the persistence stack and the PostgreSQL provider: ```bash dotnet add package Granit.Persistence dotnet add package Granit.Security dotnet add package Granit.Guids dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL ``` `Granit.Persistence` brings in `Granit.Core` and `Granit.Timing` transitively, so you do not need to add those manually. ## Update the domain entity [Section titled “Update the domain entity”](#update-the-domain-entity) Replace the plain `TaskItem` class with one that inherits from `FullAuditedEntity`. This gives you `Id`, `CreatedAt`, `CreatedBy`, `ModifiedAt`, `ModifiedBy`, `IsDeleted`, `DeletedAt`, and `DeletedBy` for free. Models/TaskItem.cs ```csharp using Granit.Core.Domain; public sealed class TaskItem : FullAuditedEntity { public string Title { get; set; } = string.Empty; public string? Description { get; set; } public bool IsCompleted { get; set; } } ``` ## Create the DbContext [Section titled “Create the DbContext”](#create-the-dbcontext) Add a `Data/TaskDbContext.cs` file: Data/TaskDbContext.cs ```csharp using Granit.Core.DataFiltering; using Granit.Core.MultiTenancy; using Granit.Persistence.Extensions; using Microsoft.EntityFrameworkCore; public sealed class TaskDbContext( DbContextOptions options, ICurrentTenant? currentTenant = null, IDataFilter? dataFilter = null) : DbContext(options) { public DbSet Tasks => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); modelBuilder.Entity(e => { e.ToTable("tasks"); e.Property(x => x.Title).HasMaxLength(200).IsRequired(); e.Property(x => x.Description).HasMaxLength(2000); }); modelBuilder.ApplyGranitConventions(currentTenant, dataFilter); } } ``` The `ApplyGranitConventions` call at the end of `OnModelCreating` registers query filters for soft delete, multi-tenancy, and other cross-cutting concerns. Never add manual `HasQueryFilter` calls — the conventions handle all standard filters centrally. ## Update the module [Section titled “Update the module”](#update-the-module) Declare the persistence dependency and register the DbContext: TaskManagementModule.cs ```csharp using Granit.Core.Modularity; using Granit.Persistence; using Granit.Persistence.Extensions; using Granit.Timing; using Microsoft.EntityFrameworkCore; [DependsOn(typeof(GranitTimingModule))] [DependsOn(typeof(GranitPersistenceModule))] public sealed class TaskManagementModule : GranitModule { public override void ConfigureServices(ServiceConfigurationContext context) { context.Services.AddGranitDbContext(options => { options.UseNpgsql( context.Configuration.GetConnectionString("Default")); }); } } ``` `AddGranitDbContext` wires up the four interceptors (`AuditedEntityInterceptor`, `VersioningInterceptor`, `DomainEventDispatcherInterceptor`, `SoftDeleteInterceptor`) automatically. You do not need to resolve them yourself. ## Add the connection string [Section titled “Add the connection string”](#add-the-connection-string) In `appsettings.Development.json`: ```json { "ConnectionStrings": { "Default": "Host=localhost;Database=task_management;Username=postgres;Password=postgres" } } ``` ## Create request and response types [Section titled “Create request and response types”](#create-request-and-response-types) Define DTOs for the API. Input bodies use the `Request` suffix, return types use `Response`. Never expose EF entities directly. Endpoints/TaskContracts.cs ```csharp public sealed record CreateTaskRequest(string Title, string? Description); public sealed record UpdateTaskRequest(string Title, string? Description, bool IsCompleted); public sealed record TaskResponse( Guid Id, string Title, string? Description, bool IsCompleted, DateTimeOffset CreatedAt, string CreatedBy); ``` ## Create CRUD endpoints [Section titled “Create CRUD endpoints”](#create-crud-endpoints) Add `Endpoints/TaskEndpoints.cs` with the two-level pattern: a public extension method that creates the route group, and an internal class that contains the handlers. Endpoints/TaskEndpoints.cs ```csharp using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.EntityFrameworkCore; public static class TaskEndpointsExtensions { public static IEndpointRouteBuilder MapTaskEndpoints( this IEndpointRouteBuilder endpoints) { var group = endpoints.MapGroup("/api/tasks") .WithTags("Tasks"); TaskRoutes.Map(group); return endpoints; } } internal static class TaskRoutes { internal static void Map(RouteGroupBuilder group) { group.MapGet("/", ListAsync); group.MapGet("/{id:guid}", GetByIdAsync); group.MapPost("/", CreateAsync); group.MapPut("/{id:guid}", UpdateAsync); group.MapDelete("/{id:guid}", DeleteAsync); } private static async Task>> ListAsync( TaskDbContext db, CancellationToken cancellationToken) { var tasks = await db.Tasks .Select(t => new TaskResponse( t.Id, t.Title, t.Description, t.IsCompleted, t.CreatedAt, t.CreatedBy)) .ToListAsync(cancellationToken) .ConfigureAwait(false); return TypedResults.Ok(tasks); } private static async Task, NotFound>> GetByIdAsync( Guid id, TaskDbContext db, CancellationToken cancellationToken) { var task = await db.Tasks .Where(t => t.Id == id) .Select(t => new TaskResponse( t.Id, t.Title, t.Description, t.IsCompleted, t.CreatedAt, t.CreatedBy)) .FirstOrDefaultAsync(cancellationToken) .ConfigureAwait(false); return task is not null ? TypedResults.Ok(task) : TypedResults.NotFound(); } private static async Task> CreateAsync( CreateTaskRequest request, TaskDbContext db, CancellationToken cancellationToken) { var task = new TaskItem { Title = request.Title, Description = request.Description }; db.Tasks.Add(task); await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); var response = new TaskResponse( task.Id, task.Title, task.Description, task.IsCompleted, task.CreatedAt, task.CreatedBy); return TypedResults.Created($"/api/tasks/{task.Id}", response); } private static async Task> UpdateAsync( Guid id, UpdateTaskRequest request, TaskDbContext db, CancellationToken cancellationToken) { var task = await db.Tasks .FirstOrDefaultAsync(t => t.Id == id, cancellationToken) .ConfigureAwait(false); if (task is null) return TypedResults.NotFound(); task.Title = request.Title; task.Description = request.Description; task.IsCompleted = request.IsCompleted; await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); return TypedResults.NoContent(); } private static async Task> DeleteAsync( Guid id, TaskDbContext db, CancellationToken cancellationToken) { var task = await db.Tasks .FirstOrDefaultAsync(t => t.Id == id, cancellationToken) .ConfigureAwait(false); if (task is null) return TypedResults.NotFound(); db.Tasks.Remove(task); await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); return TypedResults.NoContent(); } } ``` ## Wire the endpoints [Section titled “Wire the endpoints”](#wire-the-endpoints) In `Program.cs`, after `var app = builder.Build();`: ```csharp app.MapTaskEndpoints(); ``` ## Create and apply migrations [Section titled “Create and apply migrations”](#create-and-apply-migrations) Install the EF Core tools if you have not already, then generate the initial migration and apply it: ```bash dotnet tool install --global dotnet-ef dotnet ef migrations add InitialCreate dotnet ef database update ``` Start the application: ```bash dotnet run ``` Test the API with `curl`: * Create ```bash curl -s -X POST http://localhost:5000/api/tasks \ -H "Content-Type: application/json" \ -d '{"title": "Write docs", "description": "Finish the persistence tutorial"}' ``` * List ```bash curl -s http://localhost:5000/api/tasks | jq ``` * Delete (soft) ```bash curl -s -X DELETE http://localhost:5000/api/tasks/{id} ``` The row remains in the `tasks` table with `is_deleted = true`. ## What the interceptors do [Section titled “What the interceptors do”](#what-the-interceptors-do) You never wrote a single line of audit code. The interceptors fire on every `SaveChanges` call, regardless of which endpoint triggered it. | Interceptor | Trigger | Effect | | -------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------ | | `AuditedEntityInterceptor` | `EntityState.Added` | Sets `CreatedAt`, `CreatedBy`, assigns a sequential GUID to `Id` if empty, injects `TenantId` on `IMultiTenant` entities | | `AuditedEntityInterceptor` | `EntityState.Modified` | Sets `ModifiedAt`, `ModifiedBy`, protects `CreatedAt`/`CreatedBy` from accidental overwrite | | `SoftDeleteInterceptor` | `EntityState.Deleted` | Converts DELETE to UPDATE: sets `IsDeleted = true`, `DeletedAt`, `DeletedBy` | ## Next step [Section titled “Next step”](#next-step) The API is open to everyone right now. In the next tutorial, you will lock it down with JWT Bearer authentication and Keycloak integration. [Adding Authentication](/getting-started/adding-authentication/) # Next Steps > Where to go from here — concepts, guides, and reference documentation Congratulations. You now have a working Granit API with the module system, persistence, authentication, and observability configured. From here, the documentation is organized to let you dive into whichever area your project needs first. ## Recommended reading paths [Section titled “Recommended reading paths”](#recommended-reading-paths) Pick the goal closest to what you are building and follow the link to get started. | Goal | Start here | | ----------------------------------- | --------------------------------------------------------------- | | Understand the module system deeply | [Module System concept](/concepts/module-system/) | | Add multi-tenancy | [Multi-Tenancy concept](/concepts/multi-tenancy/) | | Set up notifications | [Notifications reference](/reference/modules/notifications/) | | Import data from CSV/Excel | [DataExchange reference](/reference/modules/data-exchange/) | | Generate PDF documents | [Templating reference](/reference/modules/templating/) | | Add background jobs | [BackgroundJobs reference](/reference/modules/background-jobs/) | | Deploy to production | Operations (coming soon) | ## Explore the module reference [Section titled “Explore the module reference”](#explore-the-module-reference) Granit has 135 packages organized into categories: Core, Security, Data, Messaging, Documents, Workflow, Observability, and more. The [Reference overview](/reference/) shows every module with a short description of its role, so you can quickly find the right package for your use case. Each reference page covers: * What the module does and when to use it * Installation and module dependency declaration * Configuration options * Code examples for common scenarios * Related modules and integration points ## Architecture decisions [Section titled “Architecture decisions”](#architecture-decisions) Curious why Granit uses CQRS, isolated DbContexts, or Channel fallbacks? The [Architecture](/architecture/) section documents the design decisions behind the framework, including the constraints that shaped them (GDPR, ISO 27001, multi-tenancy). Understanding these decisions will help you work with the framework rather than against it, especially when you encounter patterns like separate Reader/Writer interfaces or the isolated DbContext checklist. ## Community and contributing [Section titled “Community and contributing”](#community-and-contributing) Granit is open-source under the Apache-2.0 license. Contributions are welcome, whether that is fixing a bug, improving documentation, or proposing a new module. Check the [Contributing section](/contributing/) to learn about the development workflow, coding conventions, and Definition of Done that all contributions must satisfy. # Project Templates > Bootstrap new projects with dotnet new templates — granit-api, granit-api-full, and granit-module Granit ships `dotnet new` templates that scaffold fully configured projects in seconds. Each template wires up the module system, dependency injection, and configuration so you can focus on your domain logic. ## Install the templates [Section titled “Install the templates”](#install-the-templates) ```bash dotnet new install Granit.Templates ``` To verify the installation: ```bash dotnet new list granit ``` You should see three templates: `granit-api`, `granit-api-full`, and `granit-module`. ## Choose a template [Section titled “Choose a template”](#choose-a-template) * granit-api (Minimal) A lightweight API project with the essential Granit modules: Core, Timing, Persistence, Security, and Observability. ```bash dotnet new granit-api -n MyApp ``` **What gets generated:** * `Program.cs` with the Granit host builder * `AppModule.cs` declaring module dependencies * `appsettings.json` with default configuration **Best for:** learning Granit, prototyping, and small microservices where you want to pick additional modules yourself. * granit-api-full (Full SaaS) Everything in `granit-api`, plus MultiTenancy, Caching, Notifications, BackgroundJobs, and Localization pre-configured and ready to run. ```bash dotnet new granit-api-full -n MyApp ``` **What gets generated:** * Full API project with all production modules wired * Docker Compose with PostgreSQL, Redis, and Keycloak * Health checks and readiness probes * Keycloak realm configuration **Best for:** production SaaS applications that need tenant isolation, background processing, and notification delivery from day one. * granit-module (Library) Creates a reusable Granit module package following the isolated DbContext pattern and standard project structure. ```bash dotnet new granit-module -n MyCompany.Billing ``` **What gets generated:** * Module class with `[DependsOn]` attributes * Tests project with xUnit, Shouldly, and NSubstitute * README with package documentation **Best for:** extracting shared business logic into a distributable NuGet package that other Granit applications can consume. ## Project structure [Section titled “Project structure”](#project-structure) Running `dotnet new granit-api -n MyApp` produces the following layout: ```plaintext MyApp/ ├── src/MyApp.Api/ │ ├── Program.cs │ ├── AppModule.cs │ ├── appsettings.json │ └── appsettings.Development.json └── tests/MyApp.Api.Tests/ └── AppModuleTests.cs ``` * **`Program.cs`** configures the Granit host, loads modules, and starts the application. * **`AppModule.cs`** declares which Granit modules your application depends on using `[DependsOn(typeof(...))]` attributes. * **`appsettings.json`** contains connection strings, observability settings, and module configuration with sensible defaults. * **`AppModuleTests.cs`** verifies that the module graph resolves correctly and the application starts without errors. ## Template parameters [Section titled “Template parameters”](#template-parameters) All templates accept standard `dotnet new` parameters. The most commonly used: | Parameter | Description | Default | | ------------- | ------------------------------- | ----------------- | | `-n` | Project name and root namespace | `GranitApp` | | `-o` | Output directory | Current directory | | `--framework` | Target framework | `net10.0` | ## What to read next [Section titled “What to read next”](#what-to-read-next) With your project scaffolded, continue to the next page to explore the documentation and find the guides most relevant to your use case. [Next Steps](/getting-started/next-steps/) # Your First API > Create a Granit module, wire it into Program.cs, and expose your first endpoint In this first step, you will create a new .NET project, define a Granit module, add a domain model, and expose a working endpoint — all with zero manual DI configuration. ## 1. Create the project [Section titled “1. Create the project”](#1-create-the-project) Open a terminal and scaffold a new Minimal API project: ```bash dotnet new web -n TaskManagement.Api cd TaskManagement.Api ``` Add the two foundational Granit packages: ```bash dotnet add package Granit.Core dotnet add package Granit.Timing ``` `Granit.Core` provides the module system and shared domain types. `Granit.Timing` provides `IClock`, a testable abstraction over `DateTimeOffset.UtcNow`. ## 2. Create the root module [Section titled “2. Create the root module”](#2-create-the-root-module) Every Granit application has a root module. Modules declare their dependencies via the `[DependsOn]` attribute — the framework resolves the full dependency graph at startup. Create `TaskManagementModule.cs` in the project root: ```csharp using Granit.Core.Modularity; using Granit.Timing; namespace TaskManagement.Api; [DependsOn(typeof(GranitTimingModule))] public sealed class TaskManagementModule : GranitModule { } ``` This single attribute tells Granit to load `GranitTimingModule` (and its own transitive dependencies) before your module. No `services.AddSingleton(...)` call needed. ## 3. Wire into Program.cs [Section titled “3. Wire into Program.cs”](#3-wire-into-programcs) Replace the generated `Program.cs` with: ```csharp using Granit.Timing; using TaskManagement.Api; var builder = WebApplication.CreateBuilder(args); await builder.AddGranitAsync(); var app = builder.Build(); app.UseGranit(); app.MapGet("/", (IClock clock) => new { Message = "Task Management API", CurrentTime = clock.Now }); app.Run(); ``` `AddGranitAsync()` walks the module dependency graph, calls each module’s `ConfigureServices` in order, and registers everything into the standard `IServiceCollection`. `UseGranit()` does the same for middleware. ## 4. Run it [Section titled “4. Run it”](#4-run-it) ```bash dotnet run ``` Open `http://localhost:5000` (or whichever port your console shows). You should see a JSON response: ```json { "message": "Task Management API", "currentTime": "2026-03-13T10:42:00+00:00" } ``` ## 5. Define the domain model [Section titled “5. Define the domain model”](#5-define-the-domain-model) Create a `Domain` folder and add `TaskItem.cs`: ```csharp using Granit.Core.Domain; namespace TaskManagement.Api.Domain; public sealed class TaskItem : AuditedEntity { public string Title { get; set; } = string.Empty; public string? Description { get; set; } public bool IsCompleted { get; set; } public DateTimeOffset? DueDate { get; set; } } ``` `AuditedEntity` inherits from `Entity` and gives you five fields out of the box: `Id` (Guid), `CreatedAt`, `CreatedBy`, `ModifiedAt`, and `ModifiedBy`. The persistence layer fills these automatically via EF Core interceptors — you never set them manually. ### Entity hierarchy [Section titled “Entity hierarchy”](#entity-hierarchy) Granit provides a hierarchy of base classes. Pick the one that matches your requirements: | Base class | Fields added | Use case | | ----------------------- | --------------------------------------- | ----------------------------------- | | `Entity` | `Id` | Simple entities with no audit needs | | `CreationAuditedEntity` | + `CreatedAt`, `CreatedBy` | Write-once records (logs, events) | | `AuditedEntity` | + `ModifiedAt`, `ModifiedBy` | Most business entities | | `FullAuditedEntity` | + `IsDeleted`, `DeletedAt`, `DeletedBy` | GDPR-compliant soft delete | ## 6. Add a first endpoint [Section titled “6. Add a first endpoint”](#6-add-a-first-endpoint) Now expose a basic in-memory endpoint so you can verify the domain model compiles and serializes correctly. Add the following to `Program.cs`, before `app.Run()`: ```csharp app.MapGet("/tasks/sample", (IClock clock) => { var task = new TaskManagement.Api.Domain.TaskItem { Title = "Write documentation", Description = "Create the Getting Started tutorial", DueDate = clock.Now.AddDays(7) }; return Results.Ok(task); }); ``` Run the application again and visit `/tasks/sample`. You will see the `TaskItem` serialized as JSON, including the audit fields (which are default values for now — they will be populated once we add persistence). ## 7. What just happened [Section titled “7. What just happened”](#7-what-just-happened) In roughly 30 lines of code, you have: * A modular application with dependency resolution * A testable time abstraction (no `DateTime.Now` anywhere) * A domain entity with built-in audit fields * A working HTTP endpoint The module system is the foundation of every Granit application. Each package you add later (persistence, authentication, observability) follows the same pattern: add the NuGet package, add a `[DependsOn]` attribute, and the module registers its services. ## Next step [Section titled “Next step”](#next-step) The in-memory sample endpoint is useful for testing, but real applications need a database. In the next step, you will add EF Core with PostgreSQL and get automatic audit trail population. [Adding Persistence](/getting-started/adding-persistence/) # Guides > Step-by-step how-to guides for common tasks Task-oriented guides that show you how to accomplish specific goals with Granit. Each guide assumes you have a working Granit application. If you are starting from scratch, begin with [Getting Started](/getting-started/). ## Modules and endpoints [Section titled “Modules and endpoints”](#modules-and-endpoints) * [Create a Module](./create-a-module/) — build a new Granit module from scratch * [Add an Endpoint](./add-an-endpoint/) — Minimal API with validation and Problem Details * [Configure Multi-Tenancy](./configure-multi-tenancy/) — shared DB, per-schema, or per-database ## Messaging and events [Section titled “Messaging and events”](#messaging-and-events) * [Set Up Notifications](./set-up-notifications/) — 6-channel notification engine * [Implement Data Import](./implement-data-import/) — CSV/Excel import pipeline * [Add Background Jobs](./add-background-jobs/) — recurring and delayed jobs * [Configure Blob Storage](./configure-blob-storage/) — S3-compatible file storage * [Implement Webhooks](./implement-webhooks/) — event delivery with retry ## Features and settings [Section titled “Features and settings”](#features-and-settings) * [Add Feature Flags](./add-feature-flags/) — toggle, numeric, and selection features * [Set Up Localization](./set-up-localization/) — 17 cultures, source-generated keys * [Use Reference Data](./use-reference-data/) — i18n reference tables * [Manage Application Settings](./manage-application-settings/) — runtime settings store ## Documents and workflow [Section titled “Documents and workflow”](#documents-and-workflow) * [Create Document Templates](./create-document-templates/) — Scriban, PDF, Excel * [Implement Workflow](./implement-workflow/) — FSM engine, publication lifecycle ## Caching, versioning, and API [Section titled “Caching, versioning, and API”](#caching-versioning-and-api) * [Configure Caching](./configure-caching/) — memory, Redis, HybridCache * [Add API Versioning](./add-api-versioning/) — URL segment and header versioning * [Configure Idempotency](./configure-idempotency/) — Idempotency-Key middleware ## Security and observability [Section titled “Security and observability”](#security-and-observability) * [Encrypt Sensitive Data](./encrypt-sensitive-data/) — Vault Transit and AES-256 * [Implement Audit Timeline](./implement-audit-timeline/) — entity change tracking * [End-to-End Tracing](./end-to-end-tracing/) — OpenTelemetry distributed tracing # Add an endpoint > Create a Minimal API endpoint with request/response DTOs, FluentValidation, and RFC 7807 error handling This guide walks through adding a complete Minimal API endpoint to a Granit application — from route definition to validation and error handling. ## Prerequisites [Section titled “Prerequisites”](#prerequisites) * A working Granit module (see [Create a module](/guides/create-a-module/)) * References to `Granit.Validation` and `Granit.ExceptionHandling` ## Step 1 — Define request and response DTOs [Section titled “Step 1 — Define request and response DTOs”](#step-1--define-request-and-response-dtos) DTOs follow strict naming conventions: * **Input bodies** use the `Request` suffix * **Top-level returns** use the `Response` suffix * **Never** use the `Dto` suffix * **Prefix** with the module context to avoid OpenAPI schema conflicts ```csharp namespace Granit.Inventory.Endpoints; public sealed record InventoryItemCreateRequest( string Name, string Sku, int Quantity, decimal UnitPrice); public sealed record InventoryItemResponse( Guid Id, string Name, string Sku, int Quantity, decimal UnitPrice, DateTimeOffset CreatedAt); public sealed record InventoryItemListResponse( IReadOnlyList Items, int TotalCount); ``` Warning EF Core entities must never be returned directly from endpoints. Always create a `*Response` record to control the API surface and avoid leaking internal details. ## Step 2 — Add FluentValidation [Section titled “Step 2 — Add FluentValidation”](#step-2--add-fluentvalidation) Create a validator for the request DTO. Validators are discovered automatically if the module uses Wolverine (`[assembly: WolverineHandlerModule]`). Otherwise, register them manually. ```csharp using FluentValidation; namespace Granit.Inventory.Endpoints; public sealed class InventoryItemCreateRequestValidator : AbstractValidator { public InventoryItemCreateRequestValidator() { RuleFor(x => x.Name) .NotEmpty() .MaximumLength(200); RuleFor(x => x.Sku) .NotEmpty() .MaximumLength(50); RuleFor(x => x.Quantity) .GreaterThanOrEqualTo(0); RuleFor(x => x.UnitPrice) .GreaterThan(0); } } ``` * With Wolverine (automatic) Modules decorated with `[assembly: WolverineHandlerModule]` get automatic validator discovery via `AddGranitWolverine()`. No manual registration needed. * Without Wolverine (manual) Modules without Wolverine handlers must register validators explicitly: ```csharp public override void ConfigureServices(ServiceConfigurationContext context) { context.Services .AddGranitValidatorsFromAssemblyContaining(); } ``` Without this registration, `FluentValidationEndpointFilter` silently skips validation. ## Step 3 — Create the endpoint class [Section titled “Step 3 — Create the endpoint class”](#step-3--create-the-endpoint-class) Endpoints are static classes with handler methods. Use `TypedResults` for strongly-typed return values. ```csharp using Microsoft.AspNetCore.Http.HttpResults; namespace Granit.Inventory.Endpoints; public static class InventoryItemEndpoints { public static RouteGroupBuilder MapInventoryItemEndpoints( this RouteGroupBuilder group) { var items = group.MapGroup("/inventory-items") .WithTags("Inventory"); items.MapGet("/", GetAllAsync); items.MapGet("/{id:guid}", GetByIdAsync); items.MapPost("/", CreateAsync); items.MapPut("/{id:guid}", UpdateAsync); items.MapDelete("/{id:guid}", DeleteAsync); return group; } private static async Task> GetAllAsync( IInventoryService service, CancellationToken cancellationToken) { var result = await service.GetAllAsync(cancellationToken); return TypedResults.Ok(result); } private static async Task, ProblemHttpResult>> GetByIdAsync( Guid id, IInventoryService service, CancellationToken cancellationToken) { var item = await service.GetByIdAsync(id, cancellationToken); return item is null ? TypedResults.Problem( detail: $"Inventory item {id} not found.", statusCode: StatusCodes.Status404NotFound) : TypedResults.Ok(item); } private static async Task> CreateAsync( InventoryItemCreateRequest request, IInventoryService service, CancellationToken cancellationToken) { var item = await service.CreateAsync(request, cancellationToken); return TypedResults.Created( $"/api/v1/inventory-items/{item.Id}", item); } private static async Task> UpdateAsync( Guid id, InventoryItemUpdateRequest request, IInventoryService service, CancellationToken cancellationToken) { var success = await service.UpdateAsync( id, request, cancellationToken); return success ? TypedResults.NoContent() : TypedResults.Problem( detail: $"Inventory item {id} not found.", statusCode: StatusCodes.Status404NotFound); } private static async Task> DeleteAsync( Guid id, IInventoryService service, CancellationToken cancellationToken) { var success = await service.DeleteAsync(id, cancellationToken); return success ? TypedResults.NoContent() : TypedResults.Problem( detail: $"Inventory item {id} not found.", statusCode: StatusCodes.Status404NotFound); } } ``` ## Step 4 — Register the endpoints in Program.cs [Section titled “Step 4 — Register the endpoints in Program.cs”](#step-4--register-the-endpoints-in-programcs) ```csharp var app = builder.Build(); await app.UseGranitAsync(); app.UseAuthentication(); app.UseAuthorization(); var apiVersionSet = app.NewApiVersionSet() .HasApiVersion(new ApiVersion(1)) .ReportApiVersions() .Build(); var api = app.MapGroup("api/v{version:apiVersion}") .WithApiVersionSet(apiVersionSet); api.MapInventoryItemEndpoints(); app.Run(); ``` ## Step 5 — Handle errors with Problem Details [Section titled “Step 5 — Handle errors with Problem Details”](#step-5--handle-errors-with-problem-details) Granit uses RFC 7807 Problem Details for all error responses. Always use `TypedResults.Problem()` instead of `TypedResults.BadRequest()`. ```csharp // Correct -- RFC 7807 Problem Details return TypedResults.Problem( detail: "SKU already exists.", statusCode: StatusCodes.Status409Conflict); // Wrong -- returns plain string, not Problem Details return TypedResults.BadRequest("SKU already exists."); ``` Granit.ExceptionHandling automatically converts unhandled exceptions into Problem Details responses with appropriate status codes. The `ProblemDetailsResponseOperationTransformer` declares these error responses in the OpenAPI documentation. ### HTTP status code conventions [Section titled “HTTP status code conventions”](#http-status-code-conventions) | Code | When to use | | -------------------------- | ------------------------------------------------- | | `200 OK` | Request processed, result in body | | `201 Created` | Resource created, include `Location` header | | `204 No Content` | Operation succeeded, nothing to return | | `400 Bad Request` | Syntactically invalid request (malformed JSON) | | `404 Not Found` | Resource not found (routes with `{id}` parameter) | | `409 Conflict` | Concurrency conflict or duplicate | | `422 Unprocessable Entity` | Business validation failed (FluentValidation) | ## Step 6 — Add the validation endpoint filter [Section titled “Step 6 — Add the validation endpoint filter”](#step-6--add-the-validation-endpoint-filter) To wire FluentValidation into the endpoint pipeline, add the validation filter to the route group: ```csharp var items = group.MapGroup("/inventory-items") .WithTags("Inventory") .AddEndpointFilter>(); ``` When validation fails, the filter returns a `422 Unprocessable Entity` response with Problem Details containing the validation errors. ## Complete example [Section titled “Complete example”](#complete-example) Here is the final `Program.cs` combining modules, endpoints, and validation: ```csharp using Asp.Versioning; using Granit.Core.Extensions; using MyApp.Host; var builder = WebApplication.CreateBuilder(args); await builder.AddGranitAsync(); var app = builder.Build(); await app.UseGranitAsync(); app.UseAuthentication(); app.UseAuthorization(); var apiVersionSet = app.NewApiVersionSet() .HasApiVersion(new ApiVersion(1)) .ReportApiVersions() .Build(); var api = app.MapGroup("api/v{version:apiVersion}") .WithApiVersionSet(apiVersionSet); api.MapInventoryItemEndpoints(); app.MapHealthChecks("/healthz"); app.Run(); ``` ## Next steps [Section titled “Next steps”](#next-steps) * [Add API versioning](/guides/add-api-versioning/) — version your endpoints with URL segments * [Configure multi-tenancy](/guides/configure-multi-tenancy/) — isolate data per tenant * [API & Web reference](/reference/modules/api-web/) — full API module documentation # Add API versioning > Configure URL-based API versioning with Asp.Versioning, deprecation headers, and per-version OpenAPI docs Granit.ApiVersioning integrates `Asp.Versioning.Mvc` to provide URL-segment versioning for all API endpoints. Combined with Granit.ApiDocumentation, it generates per-version OpenAPI documents and exposes them through Scalar UI. ## Prerequisites [Section titled “Prerequisites”](#prerequisites) * A working Granit application with at least one endpoint * Familiarity with [Minimal API endpoints](/guides/add-an-endpoint/) ## Step 1 — Install the packages [Section titled “Step 1 — Install the packages”](#step-1--install-the-packages) ```bash dotnet add package Granit.ApiVersioning dotnet add package Granit.ApiDocumentation ``` Add the module dependencies: ```csharp using Granit.Core.Modularity; using Granit.ApiDocumentation; [DependsOn(typeof(GranitApiDocumentationModule))] public sealed class MyAppHostModule : GranitModule { } ``` `GranitApiDocumentationModule` depends on `GranitApiVersioningModule` transitively, so you do not need to declare both. ## Step 2 — Configure versioning [Section titled “Step 2 — Configure versioning”](#step-2--configure-versioning) Add the configuration to `appsettings.json`: ```json { "ApiVersioning": { "DefaultMajorVersion": 1, "ReportApiVersions": true }, "ApiDocumentation": { "Title": "My API", "MajorVersions": [1] } } ``` | Option | Type | Default | Description | | --------------------- | ------ | ------- | ---------------------------------------------------------------------------- | | `DefaultMajorVersion` | `int` | `1` | Version assumed when client omits it | | `ReportApiVersions` | `bool` | `true` | Adds `api-supported-versions` and `api-deprecated-versions` response headers | ## Step 3 — Set up versioned route groups [Section titled “Step 3 — Set up versioned route groups”](#step-3--set-up-versioned-route-groups) Create an `ApiVersionSet` and a versioned route group in `Program.cs`: ```csharp using Asp.Versioning; var app = builder.Build(); await app.UseGranitAsync(); app.UseAuthentication(); app.UseAuthorization(); // Declare supported API versions var apiVersionSet = app.NewApiVersionSet() .HasApiVersion(new ApiVersion(1)) .ReportApiVersions() .Build(); // Create versioned route group var api = app.MapGroup("api/v{version:apiVersion}") .WithApiVersionSet(apiVersionSet); // All endpoints registered on this group inherit the version api.MapInventoryItemEndpoints(); api.MapPatientEndpoints(); // Enable Scalar UI at /scalar/v1 app.UseGranitApiDocumentation(); app.Run(); ``` Clients access endpoints using URL-segment versioning: ```text GET /api/v1/inventory-items GET /api/v1/patients/abc-123 ``` ## Step 4 — Add a new version [Section titled “Step 4 — Add a new version”](#step-4--add-a-new-version) When you need to introduce breaking changes, declare a new API version: ```csharp var apiVersionSet = app.NewApiVersionSet() .HasApiVersion(new ApiVersion(1)) .HasApiVersion(new ApiVersion(2)) .ReportApiVersions() .Build(); var api = app.MapGroup("api/v{version:apiVersion}") .WithApiVersionSet(apiVersionSet); // Available on both v1 and v2 (same behavior) api.MapGet("/patients", GetAllPatients); // Available only on v2 api.MapGet("/patients/summary", GetPatientsSummary) .MapToApiVersion(2); // Different implementations per version api.MapGet("/patients/{id}", GetPatientV1).MapToApiVersion(1); api.MapGet("/patients/{id}", GetPatientV2).MapToApiVersion(2); ``` Without `.MapToApiVersion()`, an endpoint is available on all versions declared in the `ApiVersionSet`. Use it to restrict an endpoint to a specific version. Update the documentation to generate both OpenAPI documents: ```json { "ApiDocumentation": { "MajorVersions": [1, 2] } } ``` This generates `/openapi/v1.json` and `/openapi/v2.json`, each containing only the endpoints available on that version. ## Step 5 — Deprecate an old version [Section titled “Step 5 — Deprecate an old version”](#step-5--deprecate-an-old-version) Mark a version as deprecated to signal clients they should migrate: ```csharp var apiVersionSet = app.NewApiVersionSet() .HasApiVersion(new ApiVersion(1)) .HasDeprecatedApiVersion(new ApiVersion(1)) .HasApiVersion(new ApiVersion(2)) .ReportApiVersions() .Build(); ``` Clients receive the `api-deprecated-versions: 1.0` response header on every v1 request, signaling that migration to v2 is expected. ### Deprecate individual endpoints [Section titled “Deprecate individual endpoints”](#deprecate-individual-endpoints) For finer control, deprecate specific endpoints with RFC 8594 headers: ```csharp api.MapGet("/patients/legacy", GetLegacyPatients) .Deprecated( sunsetDate: "2026-11-01", link: "https://docs.example.com/migration/v1-to-v2"); ``` Each response from this endpoint includes: ```http Deprecation: true Sunset: Sat, 01 Nov 2026 00:00:00 GMT Link: ; rel="deprecation" ``` A warning is also logged for every call to a deprecated endpoint using `[LoggerMessage]` source-generated logging. ## Step 6 — MVC controller versioning [Section titled “Step 6 — MVC controller versioning”](#step-6--mvc-controller-versioning) If your application uses MVC controllers instead of Minimal APIs: ```csharp [ApiController] [Route("api/v{version:apiVersion}/patients")] [ApiVersion("1.0")] public sealed class PatientController : ControllerBase { [HttpGet] public IActionResult GetAll() => Ok(); } [ApiController] [Route("api/v{version:apiVersion}/patients")] [ApiVersion("2.0")] public sealed class PatientV2Controller : ControllerBase { [HttpGet] public IActionResult GetAll() => Ok(); } ``` ## Versioning strategies summary [Section titled “Versioning strategies summary”](#versioning-strategies-summary) | Strategy | URL format | ISO 27001 audit trail | | ------------------------- | ------------------------------- | -------------------------------- | | URL segment (recommended) | `/api/v1/patients` | Version in access logs | | Query string (fallback) | `/api/patients?api-version=1.0` | Version in access logs | | Header (not supported) | `X-Api-Version: 1.0` | Often omitted by reverse proxies | ## Complete example [Section titled “Complete example”](#complete-example) ```csharp using Asp.Versioning; using Granit.Core.Extensions; using MyApp.Host; var builder = WebApplication.CreateBuilder(args); await builder.AddGranitAsync(); var app = builder.Build(); await app.UseGranitAsync(); app.UseAuthentication(); app.UseAuthorization(); var apiVersionSet = app.NewApiVersionSet() .HasApiVersion(new ApiVersion(1)) .HasApiVersion(new ApiVersion(2)) .HasDeprecatedApiVersion(new ApiVersion(1)) .ReportApiVersions() .Build(); var api = app.MapGroup("api/v{version:apiVersion}") .WithApiVersionSet(apiVersionSet); api.MapGet("/patients", GetAllPatients); api.MapGet("/patients/{id}", GetPatientV1).MapToApiVersion(1); api.MapGet("/patients/{id}", GetPatientV2).MapToApiVersion(2); api.MapGet("/patients/summary", GetPatientsSummary).MapToApiVersion(2); app.UseGranitApiDocumentation(); app.MapHealthChecks("/healthz"); app.Run(); ``` ## Next steps [Section titled “Next steps”](#next-steps) * [Add an endpoint](/guides/add-an-endpoint/) — create endpoints with validation and error handling * [Configure multi-tenancy](/guides/configure-multi-tenancy/) — add tenant headers to versioned APIs * [API & Web reference](/reference/modules/api-web/) — full API documentation module details # Add Background Jobs > Schedule recurring and delayed background jobs with Cronos expressions and Wolverine durable scheduling Granit.BackgroundJobs provides durable, cluster-safe recurring job scheduling built on Wolverine. Jobs are plain Wolverine messages decorated with `[RecurringJob]` — no special interfaces to implement, no duplicate executions in multi-node deployments. ## Prerequisites [Section titled “Prerequisites”](#prerequisites) * A working Granit application with `Granit.Wolverine` configured * `Granit.Wolverine.Postgresql` for transactional outbox (production) ## 1. Install the packages [Section titled “1. Install the packages”](#1-install-the-packages) ```bash dotnet add package Granit.BackgroundJobs ``` For production persistence and administration endpoints: ```bash dotnet add package Granit.BackgroundJobs.EntityFrameworkCore dotnet add package Granit.BackgroundJobs.Endpoints ``` ## 2. Register the module [Section titled “2. Register the module”](#2-register-the-module) * Production (durable) ```csharp using Granit.Core.Modularity; using Granit.BackgroundJobs; using Granit.BackgroundJobs.EntityFrameworkCore; using Granit.BackgroundJobs.Endpoints; [DependsOn( typeof(GranitBackgroundJobsModule), typeof(GranitBackgroundJobsEntityFrameworkCoreModule), typeof(GranitBackgroundJobsEndpointsModule))] public sealed class MyAppModule : GranitModule { } ``` * Development (in-memory) ```csharp using Granit.Core.Modularity; using Granit.BackgroundJobs; [DependsOn(typeof(GranitBackgroundJobsModule))] public sealed class MyAppModule : GranitModule { } ``` ## 3. Configure the store mode [Section titled “3. Configure the store mode”](#3-configure-the-store-mode) ```json { "BackgroundJobs": { "Mode": "Durable" } } ``` | Mode | Package | Behavior | | ---------- | ------------------------------------------- | ------------------------------------------------------------- | | `InMemory` | `Granit.BackgroundJobs` | State lost on restart. Use for development and tests. | | `Durable` | `Granit.BackgroundJobs.EntityFrameworkCore` | State persisted in PostgreSQL/SQL Server. Use for production. | ## 4. Define a recurring job [Section titled “4. Define a recurring job”](#4-define-a-recurring-job) A recurring job consists of a message class with the `[RecurringJob]` attribute and a standard Wolverine handler: ```csharp [RecurringJob("0 8 * * *", "daily-report")] public sealed class GenerateDailyReportCommand; public static class GenerateDailyReportHandler { public static async Task Handle( GenerateDailyReportCommand command, IReportService reportService, CancellationToken cancellationToken) { await reportService.GenerateAsync(cancellationToken); // The middleware automatically schedules the next occurrence after this returns. } } ``` The first argument is a Cronos cron expression. The second is a unique job name used for administration (pause, resume, trigger). ## 5. Cron expression reference [Section titled “5. Cron expression reference”](#5-cron-expression-reference) Granit uses [Cronos](https://github.com/HangfireIO/Cronos) for cron parsing. Both 5-field (no seconds) and 6-field (with seconds) formats are supported. | Expression | Meaning | | ---------------- | -------------------------------- | | `0 * * * *` | Every hour at minute 0 | | `0 8 * * *` | Every day at 08:00 UTC | | `0 8 * * 1` | Every Monday at 08:00 UTC | | `*/5 * * * *` | Every 5 minutes | | `0 0 1 * *` | First of each month at 00:00 UTC | | `0 */30 * * * *` | Every 30 seconds (6-field) | All occurrences are calculated in UTC. If you need tenant-local time, use `ICurrentTimezoneProvider` inside the handler. ## 6. Map administration endpoints [Section titled “6. Map administration endpoints”](#6-map-administration-endpoints) ```csharp app.MapBackgroundJobsEndpoints(); ``` Or with custom options: ```csharp app.MapBackgroundJobsEndpoints(opts => { opts.RoutePrefix = "admin/jobs"; opts.RequiredRole = "ops-team"; }); ``` ### Available routes [Section titled “Available routes”](#available-routes) | Method | Route | Description | | ------ | -------------------------- | ---------------------------------- | | `GET` | `/{prefix}` | List all jobs with status | | `GET` | `/{prefix}/{name}` | Detail of a single job | | `POST` | `/{prefix}/{name}/pause` | Suspend scheduling | | `POST` | `/{prefix}/{name}/resume` | Resume scheduling | | `POST` | `/{prefix}/{name}/trigger` | Execute immediately (202 Accepted) | All endpoints require the `BackgroundJobs.Jobs.Manage` permission. ## 7. Administer jobs programmatically [Section titled “7. Administer jobs programmatically”](#7-administer-jobs-programmatically) Inject `IBackgroundJobManager` to control jobs from your own code: ```csharp public sealed class MaintenanceService(IBackgroundJobManager jobManager) { public async Task PauseReportingAsync(CancellationToken cancellationToken) { await jobManager.PauseAsync("daily-report", cancellationToken); } public async Task TriggerNowAsync(CancellationToken cancellationToken) { await jobManager.TriggerNowAsync("daily-report", cancellationToken); } } ``` ### Job status [Section titled “Job status”](#job-status) `IBackgroundJobManager.GetAllAsync()` returns a list of `BackgroundJobStatus` records: ```csharp public sealed record BackgroundJobStatus( string JobName, string CronExpression, bool IsEnabled, DateTimeOffset? LastExecutedAt, DateTimeOffset? NextExecutionAt, int ConsecutiveFailures, long DeadLetterCount, string? LastError); ``` ## Security [Section titled “Security”](#security) ### Endpoint authorization [Section titled “Endpoint authorization”](#endpoint-authorization) The endpoints are protected by the `BackgroundJobs.Jobs.Manage` permission. In production, grant this permission using one of two approaches: * AdminRoles bypass ```json { "Authorization": { "AdminRoles": ["admin", "ops-team"] } } ``` * Fine-grained (per-tenant) ```csharp await permissionManager.SetAsync( "BackgroundJobs.Jobs.Manage", "ops-team", tenantId: null, isGranted: true); ``` ### Audit trail [Section titled “Audit trail”](#audit-trail) Manual triggers via `TriggerNowAsync` inject an `X-Triggered-By` header with the operator’s user ID. This is recorded in the job store for ISO 27001 traceability. ## Next steps [Section titled “Next steps”](#next-steps) * [Set up notifications](/guides/set-up-notifications/) to alert users when jobs complete * [Implement webhooks](/guides/implement-webhooks/) to notify external systems of job results * [Granit.BackgroundJobs reference](/reference/modules/background-jobs/) for the full API surface and store interfaces # Add feature flags > Define toggle, numeric, and selection features with cascading resolution, quota guards, and EF Core persistence Granit.Features provides a SaaS-oriented feature management system. Features are resolved through a cascade (Tenant override, Plan value, Default) and cached with hybrid L1/L2 caching. Three value types cover common scenarios: toggles, numeric quotas, and selection lists. ## Prerequisites [Section titled “Prerequisites”](#prerequisites) * A .NET 10 project referencing `Granit.Core` * `Granit.Features` for feature definitions and resolution * `Granit.Features.EntityFrameworkCore` for database-backed overrides (optional) ## Step 1 — Install packages [Section titled “Step 1 — Install packages”](#step-1--install-packages) ```bash # Core: definitions, resolution, ASP.NET Core integration, Wolverine middleware dotnet add package Granit.Features # EF Core persistence for tenant overrides (production) dotnet add package Granit.Features.EntityFrameworkCore ``` ## Step 2 — Register the module [Section titled “Step 2 — Register the module”](#step-2--register-the-module) * Single-tenant ```csharp [DependsOn(typeof(GranitFeaturesModule))] public sealed class AppModule : GranitModule { } ``` Multi-tenancy is not required. If `ICurrentTenant` is not registered, the Tenant level of the cascade is silently skipped. * Multi-tenant with EF Core ```csharp [DependsOn(typeof(GranitFeaturesModule))] [DependsOn(typeof(GranitFeaturesEntityFrameworkCoreModule))] [DependsOn(typeof(GranitMultiTenancyModule))] public sealed class AppModule : GranitModule { } ``` ## Step 3 — Define features [Section titled “Step 3 — Define features”](#step-3--define-features) Create a definition provider that declares all features for your application. Each feature has a name, a default value, and a value type. ```csharp using Granit.Features.Definitions; using Granit.Features.ValueTypes; namespace MyApp.Features; public sealed class AppFeatureDefinitionProvider : IFeatureDefinitionProvider { public void Define(IFeatureDefinitionContext context) { var saas = context.AddGroup("App", "Application features"); // Toggle: on/off saas.AddFeature("App.VideoConsultation", defaultValue: "false", valueType: FeatureValueType.Toggle, displayName: "Video consultation"); // Numeric: integer quota with constraints saas.AddFeature("App.MaxPatients", defaultValue: "50", valueType: FeatureValueType.Numeric, displayName: "Patient quota", numericConstraint: new NumericConstraint(min: 0, max: 10_000)); // Selection: one value from a predefined list saas.AddFeature("App.Plan", defaultValue: "starter", valueType: FeatureValueType.Selection, displayName: "Commercial plan"); } } ``` Register the provider at startup: ```csharp services.AddFeatureDefinitions(); ``` ## Step 4 — Check features in code [Section titled “Step 4 — Check features in code”](#step-4--check-features-in-code) ### Read feature values with IFeatureChecker [Section titled “Read feature values with IFeatureChecker”](#read-feature-values-with-ifeaturechecker) ```csharp using Granit.Features.Checker; namespace MyApp.Services; public sealed class VideoConsultationService(IFeatureChecker featureChecker) { public async Task IsAvailableAsync( CancellationToken cancellationToken) => await featureChecker.IsEnabledAsync( "App.VideoConsultation", cancellationToken); public async Task GetPatientQuotaAsync( CancellationToken cancellationToken) => await featureChecker.GetNumericAsync( "App.MaxPatients", cancellationToken); } ``` ### Guard numeric limits [Section titled “Guard numeric limits”](#guard-numeric-limits) `IFeatureLimitGuard` throws `FeatureLimitExceededException` when a count reaches or exceeds the configured quota: ```csharp using Granit.Features.Limits; namespace MyApp.Services; public sealed class PatientService( IFeatureLimitGuard limitGuard, IPatientRepository repo) { public async Task CreateAsync( CreatePatientCommand cmd, CancellationToken cancellationToken) { var currentCount = await repo.CountAsync(cancellationToken); await limitGuard.GuardAsync( "App.MaxPatients", currentCount, cancellationToken); // Throws FeatureLimitExceededException if currentCount >= limit await repo.AddAsync(cmd.ToEntity(), cancellationToken); } } ``` ### Protect endpoints with \[RequiresFeature] [Section titled “Protect endpoints with \[RequiresFeature\]”](#protect-endpoints-with-requiresfeature) The `[RequiresFeature]` attribute returns HTTP 403 with a `ProblemDetails` response (`type: upgrade_required`) when the feature is disabled: ```csharp [RequiresFeature("App.VideoConsultation")] app.MapPost("/video-sessions", CreateVideoSession); ``` ### Protect Wolverine handlers [Section titled “Protect Wolverine handlers”](#protect-wolverine-handlers) The same attribute works on Wolverine message handlers. Messages are rejected before handler execution: ```csharp public sealed class CreateVideoSessionHandler { [RequiresFeature("App.VideoConsultation")] public async Task Handle( CreateVideoSessionCommand cmd, CancellationToken cancellationToken) { // Only runs if the feature is enabled for the current tenant } } ``` ## Step 5 — Understand the resolution cascade [Section titled “Step 5 — Understand the resolution cascade”](#step-5--understand-the-resolution-cascade) Feature values are resolved through a three-level cascade. The first non-null value wins: ```text Tenant override (priority 100) | null -> next level Plan value (priority 200) | null -> next level Default (priority 300) <- value declared in code ``` Results are cached by `IHybridCache` (L1 in-process + L2 Redis) and invalidated via `FeatureValueChangedEvent` through Wolverine. ## Step 6 — Implement Plan resolution (optional) [Section titled “Step 6 — Implement Plan resolution (optional)”](#step-6--implement-plan-resolution-optional) To enable the Plan level in the cascade, provide two implementations: ```csharp using Granit.Features.Plans; namespace MyApp.Features; // Resolves the current tenant's plan identifier public sealed class AppPlanIdProvider( ICurrentTenant currentTenant, AppDbContext db) : IPlanIdProvider { public async Task GetCurrentPlanIdAsync( CancellationToken cancellationToken) { if (!currentTenant.IsAvailable) { return null; } var tenant = await db.Tenants.FindAsync( [currentTenant.Id], cancellationToken); return tenant?.PlanId; } } // Retrieves feature values for a given plan public sealed class AppPlanFeatureStore(AppDbContext db) : IPlanFeatureStore { public async Task GetOrNullAsync( string planId, string featureName, CancellationToken cancellationToken) => await db.PlanFeatures .Where(f => f.PlanId == planId && f.FeatureName == featureName) .Select(f => f.Value) .FirstOrDefaultAsync(cancellationToken); } ``` Register them in `Program.cs`: ```csharp services.AddSingleton(); services.AddSingleton(); ``` ## Step 7 — Persist tenant overrides with EF Core [Section titled “Step 7 — Persist tenant overrides with EF Core”](#step-7--persist-tenant-overrides-with-ef-core) `GranitFeaturesEntityFrameworkCoreModule` replaces `InMemoryFeatureStore` with `EfCoreFeatureStore`. Overrides are stored in the `saas_feature_overrides` table with full ISO 27001 audit trail. Create the migration: ```bash dotnet ef migrations add AddFeatureOverrides --context FeaturesDbContext ``` ### Invalidate cache on override changes [Section titled “Invalidate cache on override changes”](#invalidate-cache-on-override-changes) After modifying a tenant override, publish `FeatureValueChangedEvent` to invalidate the hybrid cache across all instances: ```csharp using Granit.Features.Events; namespace MyApp.Features; public sealed class OverrideTenantFeatureHandler( IFeatureStoreWriter store, IMessageBus bus) { public async Task Handle( OverrideTenantFeatureCommand cmd, CancellationToken cancellationToken) { await store.SetAsync( cmd.FeatureName, cmd.TenantId, cmd.Value, cancellationToken); await bus.PublishAsync( new FeatureValueChangedEvent(cmd.TenantId, cmd.FeatureName), cancellationToken); } } ``` ## Exceptions [Section titled “Exceptions”](#exceptions) | Exception | Trigger | | --------------------------------- | -------------------------------------------------------------- | | `FeatureNotEnabledException` | `IFeatureChecker.RequireEnabledAsync()` — feature is disabled | | `FeatureLimitExceededException` | `IFeatureLimitGuard.GuardAsync()` — quota exceeded | | `FeatureNotFoundException` | `IFeatureDefinitionStore.GetRequired()` — unknown feature name | | `FeatureValueValidationException` | Value incompatible with the feature’s `ValueType` constraints | ## Next steps [Section titled “Next steps”](#next-steps) * [Manage application settings](/guides/manage-application-settings/) — runtime settings with cascading resolution * [Settings and Features reference](/reference/modules/settings-features/) — full API and configuration details * [Create a module](/guides/create-a-module/) — build a module that uses feature checks # Configure blob storage > Set up multi-provider file storage (S3, Azure, FileSystem, Database) with presigned URLs, validation pipeline, multi-tenant isolation, and GDPR-compliant deletion Granit.BlobStorage provides sovereign, Direct-to-Cloud file storage with a unified API across multiple providers. Cloud providers (S3, Azure Blob) use native presigned URLs where the server never handles file bytes. Server-side providers (FileSystem, Database) use `Granit.BlobStorage.Proxy` for token-based upload/download endpoints. This guide covers the S3 provider setup. For other providers, see the [BlobStorage reference](/reference/modules/blob-storage/). ## Prerequisites [Section titled “Prerequisites”](#prerequisites) * A .NET 10 project with Granit module system configured * An S3-compatible object storage endpoint (AWS S3, OVHcloud, MinIO, etc.) * A PostgreSQL (or other EF Core-supported) database for metadata persistence ## Step 1 — Install packages [Section titled “Step 1 — Install packages”](#step-1--install-packages) ```bash dotnet add package Granit.BlobStorage dotnet add package Granit.BlobStorage.S3 dotnet add package Granit.BlobStorage.EntityFrameworkCore ``` ## Step 2 — Declare modules [Section titled “Step 2 — Declare modules”](#step-2--declare-modules) Register the blob storage modules in your application module: ```csharp using Granit.BlobStorage; using Granit.BlobStorage.EntityFrameworkCore; using Granit.Core.Modularity; [DependsOn( typeof(GranitBlobStorageModule), typeof(GranitBlobStorageEntityFrameworkCoreModule))] public sealed class MyAppModule : GranitModule { } ``` ## Step 3 — Configure S3 credentials [Section titled “Step 3 — Configure S3 credentials”](#step-3--configure-s3-credentials) Add the `BlobStorage` section to `appsettings.json`: * Production (sovereign S3) ```json { "BlobStorage": { "ServiceUrl": "https://s3.rbx.io.cloud.ovh.net", "Region": "rbx", "DefaultBucket": "my-blobs", "ForcePathStyle": false, "AccessKey": "INJECT_FROM_VAULT", "SecretKey": "INJECT_FROM_VAULT" } } ``` * Development (MinIO) ```json { "BlobStorage": { "ServiceUrl": "http://localhost:9000", "Region": "us-east-1", "DefaultBucket": "my-blobs", "ForcePathStyle": true, "AccessKey": "minioadmin", "SecretKey": "minioadmin" } } ``` Warning Never store `AccessKey` and `SecretKey` in plain text in production. Inject them from `Granit.Vault` (dynamic credentials) or environment variables. | Property | Type | Default | Description | | ----------------- | --------------------- | ----------- | ----------------------------------------------------------- | | `ServiceUrl` | `string` | — | S3 endpoint (required) | | `AccessKey` | `string` | — | Access key — inject from Granit.Vault | | `SecretKey` | `string` | — | Secret key — inject from Granit.Vault | | `Region` | `string` | `us-east-1` | S3 region. European sovereign hosting: `rbx` | | `DefaultBucket` | `string` | — | Default S3 bucket (required) | | `ForcePathStyle` | `bool` | `true` | Enable for MinIO and some providers | | `TenantIsolation` | `BlobTenantIsolation` | `Prefix` | Isolation strategy: `Prefix` (single bucket, tenant prefix) | ## Step 4 — Register services [Section titled “Step 4 — Register services”](#step-4--register-services) ```csharp // S3 provider (required) builder.AddGranitBlobStorageS3(); // EF Core persistence (required in production) builder.AddGranitBlobStorageEntityFrameworkCore(options => options.UseNpgsql(connectionString)); ``` ## Step 5 — Run EF Core migrations [Section titled “Step 5 — Run EF Core migrations”](#step-5--run-ef-core-migrations) ```bash dotnet ef migrations add InitBlobStorage \ --project src/Granit.BlobStorage.EntityFrameworkCore \ --startup-project src/MyApp ``` The migration creates the `storage_blob_descriptors` table with a unique index on `ObjectKey` and a composite index on `(TenantId, ContainerName)`. ## Uploading files [Section titled “Uploading files”](#uploading-files) The upload flow uses a Direct-to-Cloud pattern: the server issues a presigned URL, and the client uploads bytes directly to S3. ### Initiate an upload [Section titled “Initiate an upload”](#initiate-an-upload) Inject `IBlobStorage` and call `InitiateUploadAsync`: ```csharp public sealed class DocumentUploadService(IBlobStorage blobStorage) { public async Task InitiateAsync( string fileName, string contentType, CancellationToken cancellationToken) { PresignedUploadTicket ticket = await blobStorage.InitiateUploadAsync( containerName: "documents", request: new BlobUploadRequest( FileName: fileName, ContentType: contentType, MaxAllowedBytes: 10_000_000L), cancellationToken: cancellationToken); // Return to the client: ticket.BlobId, ticket.UploadUrl, ticket.ExpiresAt return ticket; } } ``` The client then performs a `PUT` directly to `ticket.UploadUrl` with the file bytes. The application server never touches the file content. ### Validate after upload [Section titled “Validate after upload”](#validate-after-upload) After the client confirms the S3 upload succeeded, trigger server-side validation: ```csharp await blobStorage.ValidateAsync( containerName: "documents", blobId: ticket.BlobId, cancellationToken: cancellationToken); ``` The validation pipeline runs in order: 1. **MagicBytesValidator** (Order=10) — detects the real MIME type via magic bytes (S3 range GET, 261 bytes max) 2. **MaxSizeValidator** (Order=20) — checks `ActualSizeBytes <= MaxAllowedBytes` via S3 HEAD (no download) The `BlobDescriptor` transitions to `Valid` or `Rejected` depending on the result. ## Downloading files [Section titled “Downloading files”](#downloading-files) Generate a presigned download URL for validated blobs: ```csharp public async Task GetDownloadUrlAsync( Guid blobId, CancellationToken cancellationToken) { PresignedDownloadUrl url = await blobStorage.CreateDownloadUrlAsync( containerName: "documents", blobId: blobId, options: new DownloadUrlOptions { ExpiryOverride = TimeSpan.FromMinutes(30) }, cancellationToken: cancellationToken); return url; // url.Url: presigned URL to pass to the client // url.ExpiresAt: expiration timestamp } ``` The method throws `BlobNotFoundException` if the blob does not exist for the current tenant, and `BlobNotValidException` if the blob is not in `Valid` status. ## Deleting files (GDPR Crypto-Shredding) [Section titled “Deleting files (GDPR Crypto-Shredding)”](#deleting-files-gdpr-crypto-shredding) ```csharp await blobStorage.DeleteAsync( containerName: "documents", blobId: blobId, deletionReason: "GDPR Art. 17 -- erasure request", cancellationToken: cancellationToken); ``` The S3 object is physically deleted. The `BlobDescriptor` remains in the database with `Status = Deleted`, `DeletedAt`, and `DeletionReason` for the ISO 27001 audit trail (3-year minimum retention). ## Reading metadata (CQRS) [Section titled “Reading metadata (CQRS)”](#reading-metadata-cqrs) Blob metadata is accessed through two separate interfaces following CQRS: * `IBlobDescriptorReader` — read operations (find, list) * `IBlobDescriptorWriter` — write operations (create, update status) Warning Never merge `IBlobDescriptorReader` and `IBlobDescriptorWriter` into a single interface or constructor parameter. The framework follows Command Query Responsibility Segregation by design. ```csharp public sealed class BlobInfoService( IBlobDescriptorReader descriptorReader, IBlobDescriptorWriter descriptorWriter) { public async Task FindAsync( Guid blobId, CancellationToken cancellationToken) { return await descriptorReader.FindAsync(blobId, cancellationToken); } } ``` ## Multi-tenant isolation [Section titled “Multi-tenant isolation”](#multi-tenant-isolation) S3 object keys follow the format `{tenantId}/{containerName}/{yyyy}/{MM}/{blobId}`. The `tenantId/` prefix ensures tenant isolation without a dedicated bucket per tenant. `IBlobDescriptorReader.FindAsync` always filters by the active tenant’s `TenantId`. A tenant cannot access another tenant’s blobs, even with a valid `BlobId`. ## Adding custom validators [Section titled “Adding custom validators”](#adding-custom-validators) Implement `IBlobValidator` to add custom validation (e.g., antivirus scanning): ```csharp public sealed class AntivirusValidator : IBlobValidator { public int Order => 30; // Runs after MagicBytes (10) and MaxSize (20) public async Task ValidateAsync( BlobDescriptor descriptor, IBlobStorageClient storageClient, CancellationToken cancellationToken) { // Perform antivirus scan return BlobValidationResult.Success(); } } ``` Register it before calling `AddGranitBlobStorageS3()`: ```csharp builder.Services.AddScoped(); ``` ## BlobDescriptor lifecycle [Section titled “BlobDescriptor lifecycle”](#blobdescriptor-lifecycle) | Status | Trigger | | ----------- | ---------------------------------------------------------------- | | `Pending` | `InitiateUploadAsync()` — ticket issued, upload not yet received | | `Uploading` | S3 notification received — validation in progress | | `Valid` | All validators passed | | `Rejected` | A validator failed — S3 object already deleted | | `Deleted` | `DeleteAsync()` — GDPR Crypto-Shredding | ## Next steps [Section titled “Next steps”](#next-steps) * [Set up localization](/guides/set-up-localization/) to localize blob storage error messages (`BlobStorage:NotFound`, `BlobStorage:NotValid`) * [Set up end-to-end tracing](/guides/end-to-end-tracing/) to trace blob operations in Grafana Tempo * [Blob storage reference](/reference/modules/blob-storage/) for the complete API surface # Configure caching > Set up distributed caching with IDistributedCache, HybridCache, Redis, and AES-256 encryption Granit.Caching provides a typed cache abstraction (`ICacheService`) that works identically across three providers: in-memory for development, Redis for production, and HybridCache (L1 memory + L2 Redis) for multi-pod Kubernetes deployments. Built-in stampede protection and optional AES-256 encryption for GDPR-sensitive data. ## Prerequisites [Section titled “Prerequisites”](#prerequisites) * A working Granit application * Redis server (for production setups) ## Step 1 — Install the package [Section titled “Step 1 — Install the package”](#step-1--install-the-package) Choose the package matching your deployment target: * Development (in-memory) ```bash dotnet add package Granit.Caching ``` ```csharp [DependsOn(typeof(GranitCachingModule))] public sealed class AppModule : GranitModule { } ``` No additional configuration needed. Uses `MemoryDistributedCache` internally. * Production (Redis) ```bash dotnet add package Granit.Caching.StackExchangeRedis ``` ```csharp [DependsOn(typeof(GranitCachingRedisModule))] public sealed class AppModule : GranitModule { } ``` * Kubernetes (HybridCache) ```bash dotnet add package Granit.Caching.Hybrid ``` ```csharp [DependsOn(typeof(GranitCachingHybridModule))] public sealed class AppModule : GranitModule { } ``` Hybrid pulls in `Caching.StackExchangeRedis` transitively. ## Step 2 — Configure appsettings.json [Section titled “Step 2 — Configure appsettings.json”](#step-2--configure-appsettingsjson) * Development ```json { "Cache": { "KeyPrefix": "myapp" } } ``` * Production (Redis) ```json { "Cache": { "KeyPrefix": "myapp", "DefaultAbsoluteExpirationRelativeToNow": "01:00:00", "DefaultSlidingExpiration": "00:20:00", "EncryptValues": true, "Encryption": { "Key": "" }, "Redis": { "IsEnabled": true, "Configuration": "redis-service:6379", "InstanceName": "myapp:" } } } ``` * Kubernetes (HybridCache) ```json { "Cache": { "KeyPrefix": "myapp", "EncryptValues": true, "Encryption": { "Key": "" }, "Redis": { "IsEnabled": true, "Configuration": "redis-service:6379", "InstanceName": "myapp:" }, "Hybrid": { "LocalCacheExpiration": "00:00:30" } } } ``` Warning The AES encryption key must be provided via Vault, never stored in plain text in `appsettings.json`. The example above uses a placeholder for illustration. ## Step 3 — Use ICacheService in your code [Section titled “Step 3 — Use ICacheService in your code”](#step-3--use-icacheservice-in-your-code) ### String key (simple) [Section titled “String key (simple)”](#string-key-simple) ```csharp public sealed class UserService(ICacheService cache) { public async Task GetByIdAsync( Guid id, CancellationToken cancellationToken) { return await cache.GetOrAddAsync( id.ToString(), async ct => await LoadUserFromDatabaseAsync(id, ct), cancellationToken: cancellationToken); } } ``` ### Typed key [Section titled “Typed key”](#typed-key) ```csharp public sealed class UserService(ICacheService cache) { public async Task GetByIdAsync( Guid id, CancellationToken cancellationToken) { return await cache.GetOrAddAsync( id, async ct => await LoadUserFromDatabaseAsync(id, ct), cancellationToken: cancellationToken); } } ``` The business code is identical regardless of the provider (Memory, Redis, or Hybrid). ## Step 4 — Customize cache key naming [Section titled “Step 4 — Customize cache key naming”](#step-4--customize-cache-key-naming) Keys are built automatically using the format `{KeyPrefix}:{CacheName}:{userKey}`. | `KeyPrefix` | Cache item type | User key | Final key | | ----------- | ------------------ | -------- | --------------------- | | `myapp` | `UserCacheItem` | `d4e5f6` | `myapp:User:d4e5f6` | | `myapp` | `PatientCacheItem` | `p-001` | `myapp:Patient:p-001` | The convention automatically strips the `CacheItem` suffix from the type name. Use the `[CacheName]` attribute to override the convention: ```csharp [CacheName("Session")] public sealed class SessionData { public string UserId { get; init; } = default!; public DateTimeOffset ExpiresAt { get; init; } } ``` This produces keys like `myapp:Session:abc` instead of `myapp:SessionData:abc`. ## Step 5 — Enable encryption for sensitive data [Section titled “Step 5 — Enable encryption for sensitive data”](#step-5--enable-encryption-for-sensitive-data) ### Global encryption [Section titled “Global encryption”](#global-encryption) Set `EncryptValues: true` in the `Cache` section to encrypt all cached values with AES-256-CBC. ### Per-type control [Section titled “Per-type control”](#per-type-control) Use `[CacheEncrypted]` to control encryption independently of the global flag: ```csharp // Always encrypted, even if EncryptValues = false [CacheEncrypted] public sealed class PatientCacheItem { public string Name { get; init; } = default!; public DateOnly DateOfBirth { get; init; } } // Never encrypted, even if EncryptValues = true [CacheEncrypted(false)] public sealed class PublicConfigCacheItem { public string ThemeColor { get; init; } = default!; } // Follows the global EncryptValues flag public sealed class UserSessionCacheItem { public string Token { get; init; } = default!; } ``` The attribute takes priority over the global `EncryptValues` flag. ## Step 6 — Cache invalidation [Section titled “Step 6 — Cache invalidation”](#step-6--cache-invalidation) Remove a cached entry explicitly: ```csharp await cache.RemoveAsync("user-id", cancellationToken); ``` ### Invalidation in multi-pod deployments [Section titled “Invalidation in multi-pod deployments”](#invalidation-in-multi-pod-deployments) With HybridCache, `RemoveAsync` clears the L2 (Redis) and the local L1 cache of the calling pod. Other pods’ L1 caches expire naturally within the `LocalCacheExpiration` window (default: 30 seconds). ## Configuration reference [Section titled “Configuration reference”](#configuration-reference) ### CachingOptions (`Cache` section) [Section titled “CachingOptions (Cache section)”](#cachingoptions-cache-section) | Property | Type | Default | Description | | ---------------------------------------- | ----------- | ------- | ---------------------------------- | | `KeyPrefix` | `string` | `"dd"` | Prefix for all cache keys | | `DefaultAbsoluteExpirationRelativeToNow` | `TimeSpan?` | `1h` | Default absolute expiration | | `DefaultSlidingExpiration` | `TimeSpan?` | `20min` | Default sliding expiration | | `EncryptValues` | `bool` | `false` | Enable AES-256 encryption globally | ### RedisCachingOptions (`Cache:Redis` section) [Section titled “RedisCachingOptions (Cache:Redis section)”](#rediscachingoptions-cacheredis-section) | Property | Type | Default | Description | | --------------- | -------- | ------------------ | ------------------------------------------------ | | `IsEnabled` | `bool` | `true` | Disable Redis without changing the loaded module | | `Configuration` | `string` | `"localhost:6379"` | StackExchange.Redis connection string | | `InstanceName` | `string` | `"dd:"` | Redis key prefix for multi-app isolation | ### HybridCachingOptions (`Cache:Hybrid` section) [Section titled “HybridCachingOptions (Cache:Hybrid section)”](#hybridcachingoptions-cachehybrid-section) | Property | Type | Default | Description | | ---------------------- | ---------- | ------- | ------------------------------------------- | | `LocalCacheExpiration` | `TimeSpan` | `30s` | L1 cache TTL (bounds staleness across pods) | ## Stampede protection [Section titled “Stampede protection”](#stampede-protection) `GetOrAddAsync` guarantees the factory executes only once, even under heavy concurrency (10+ simultaneous requests for the same missing key): 1. Lock-free check (cache hit returns immediately) 2. Acquire lock (`SemaphoreSlim` stored in a bounded `IMemoryCache`, TTL 30s) 3. Double-check (another thread may have populated the cache) 4. Execute the factory (guaranteed single execution) With HybridCache, stampede protection is built into the .NET runtime — no additional `SemaphoreSlim` needed. ## Next steps [Section titled “Next steps”](#next-steps) * [Configure multi-tenancy](/guides/configure-multi-tenancy/) — tenant-aware cache keys * [Add an endpoint](/guides/add-an-endpoint/) — use cached data in your API endpoints * [Caching reference](/reference/modules/caching/) — full architecture and service registration details # Configure Idempotency > Add Stripe-style idempotency to your API endpoints so clients can safely retry requests without duplicate side effects Granit.Idempotency provides Stripe-style HTTP idempotency for your API endpoints. When a client retries a request (network failure, timeout, double-click), the middleware returns the original response without re-executing the business logic. State is managed in Redis with atomic locks and AES-256-CBC encryption. ## Prerequisites [Section titled “Prerequisites”](#prerequisites) * A working Granit application * Redis instance (used for idempotency state storage) * `Granit.Caching` configured (provides `ICacheValueEncryptor` for AES encryption) ## 1. Install the package [Section titled “1. Install the package”](#1-install-the-package) ```bash dotnet add package Granit.Idempotency ``` ## 2. Register the module [Section titled “2. Register the module”](#2-register-the-module) ```csharp using Granit.Core.Modularity; using Granit.Idempotency; [DependsOn(typeof(GranitIdempotencyModule))] public sealed class MyAppModule : GranitModule { } ``` The module reads the `Idempotency` configuration section and registers all required services automatically. ## 3. Add the middleware [Section titled “3. Add the middleware”](#3-add-the-middleware) Register the middleware in your ASP.NET Core pipeline **after** authentication and authorization — the middleware needs `ICurrentUserService` to be populated: ```csharp app.UseAuthentication(); app.UseAuthorization(); app.UseGranitIdempotency(); app.MapControllers(); ``` Warning Placing `UseGranitIdempotency()` before `UseAuthentication()` will cause the middleware to fail because the user context is not yet available. The idempotency key is scoped to `tenantId + userId` for security isolation. ## 4. Configure options [Section titled “4. Configure options”](#4-configure-options) ```json { "Idempotency": { "HeaderName": "Idempotency-Key", "KeyPrefix": "idp", "CompletedTtl": "24:00:00", "InProgressTtl": "00:00:30", "ExecutionTimeout": "00:00:25", "MaxBodySizeBytes": 1048576 } } ``` | Option | Default | Description | | ------------------ | ----------------- | ----------------------------------------- | | `HeaderName` | `Idempotency-Key` | HTTP header name | | `KeyPrefix` | `idp` | Redis key prefix | | `CompletedTtl` | 24 hours | How long completed responses are cached | | `InProgressTtl` | 30 seconds | Lock duration while request is processing | | `ExecutionTimeout` | 25 seconds | Timeout for the business handler | | `MaxBodySizeBytes` | 1,048,576 | Max body size read for hash computation | ## 5. Mark endpoints as idempotent [Section titled “5. Mark endpoints as idempotent”](#5-mark-endpoints-as-idempotent) * Minimal API ```csharp app.MapPost("/payments", CreatePaymentAsync) .WithMetadata(new IdempotentAttribute()) .WithName("CreatePayment"); ``` * Controllers ```csharp [HttpPost("payments")] [Idempotent] public async Task CreatePaymentAsync( [FromBody] CreatePaymentRequest request, CancellationToken cancellationToken) { var payment = await paymentService.CreateAsync(request, cancellationToken); return CreatedAtAction(nameof(GetPayment), new { id = payment.Id }, payment); } ``` Only endpoints decorated with `[Idempotent]` activate the middleware. All other endpoints pass through unaffected. ## 6. Client-side usage [Section titled “6. Client-side usage”](#6-client-side-usage) Clients include a unique idempotency key (UUID v4 recommended) in the request header: ```http POST /api/v1/payments HTTP/1.1 Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000 Content-Type: application/json {"amount": 100, "currency": "EUR"} ``` ### Response behavior [Section titled “Response behavior”](#response-behavior) | Scenario | Response | Header | | ------------------------------------------ | ------------------------------- | ------------------------------ | | First request (lock acquired) | Normal business response | — | | Retry with same body (response cached) | Original response replayed | `X-Idempotency-Replayed: true` | | Concurrent request (execution in progress) | `409 Conflict` | `Retry-After: 30` | | Retry with different body | `422 Unprocessable Entity` | — | | Execution timeout | `503 Service Unavailable` | — | | Server error (5xx) | Lock released, client can retry | — | ## How it works [Section titled “How it works”](#how-it-works) The middleware implements a three-state machine backed by Redis: ```text Absent --(SET NX PX)--> InProgress --(SET XX PX)--> Completed | +--(5xx / timeout)--> Absent ``` 1. **Acquire lock**: `SET NX PX` creates the key only if absent (atomic). 2. **Execute handler**: The business logic runs with a `CancellationToken` that expires at `ExecutionTimeout`. 3. **Store response**: On success, the response (status code, headers, body) is encrypted with AES-256-CBC and stored with `CompletedTtl`. 4. **On failure**: 5xx responses or exceptions release the lock so the client can retry. ### Redis key structure [Section titled “Redis key structure”](#redis-key-structure) ```text {KeyPrefix}:{tenantId}:{userId}:{METHOD}:{routePattern}:{sha256(idempotencyKey)} ``` The idempotency key value is hashed with SHA-256 before inclusion in the Redis key, preventing injection of special characters. ### Payload hash validation [Section titled “Payload hash validation”](#payload-hash-validation) The middleware computes a SHA-256 hash over `METHOD + routePattern + idempotencyKey + body`. If a retry uses the same idempotency key but a different body, the hash mismatch triggers a `422 Unprocessable Entity` response. Warning `multipart/form-data` requests are rejected with `422` because multipart boundaries change on every request, making the hash non-deterministic. ### Cached status codes [Section titled “Cached status codes”](#cached-status-codes) | Codes | Behavior | | ---------------------------- | ------------------------------------- | | 2xx, 400, 404, 409, 410, 422 | Cached and replayed | | 401, 403 | Never cached (permissions may change) | | 5xx | Lock released, client can retry | ## Security [Section titled “Security”](#security) * **Tenant isolation**: The Redis key includes `tenantId + userId`, preventing cross-tenant response replay even with identical idempotency keys. * **Encryption**: Cached responses are encrypted with AES-256-CBC via `ICacheValueEncryptor` (provided by `Granit.Caching`). This is mandatory for ISO 27001 compliance when response bodies contain health data. * **No side-channel**: The idempotency key is hashed before storage, preventing Redis key enumeration attacks. ## Next steps [Section titled “Next steps”](#next-steps) * [Granit.Caching concept](/concepts/configuration/) for distributed cache and encryption setup * [Set up notifications](/guides/set-up-notifications/) to notify users of completed operations * [Granit.Idempotency reference](/reference/modules/api-web/) for the full configuration and service registration details # Configure multi-tenancy > Set up tenant isolation with shared database, per-schema, or per-database strategies using ICurrentTenant Granit.MultiTenancy provides per-request tenant isolation via `ICurrentTenant`. The middleware reads a JWT claim or HTTP header, activates the tenant context for the duration of the request, and restores it afterward. This guide covers setup, the three isolation strategies, and common patterns. ## Prerequisites [Section titled “Prerequisites”](#prerequisites) * A working Granit application with `Granit.Persistence` * PostgreSQL (examples use `UseNpgsql()`, but any EF Core provider works) ## Step 1 — Install the package [Section titled “Step 1 — Install the package”](#step-1--install-the-package) ```bash dotnet add package Granit.MultiTenancy ``` Add the module dependency in your host module: ```csharp using Granit.Core.Modularity; using Granit.MultiTenancy; [DependsOn(typeof(GranitMultiTenancyModule))] public sealed class MyAppHostModule : GranitModule { } ``` ## Step 2 — Configure the middleware pipeline [Section titled “Step 2 — Configure the middleware pipeline”](#step-2--configure-the-middleware-pipeline) The tenant resolution middleware must be placed **after** `UseAuthentication()` (so `HttpContext.User` is populated) and **before** `UseAuthorization()`: ```csharp var app = builder.Build(); await app.UseGranitAsync(); app.UseAuthentication(); app.UseGranitMultiTenancy(); // after Authentication, before Authorization app.UseAuthorization(); app.Run(); ``` ## Step 3 — Configure tenant resolution [Section titled “Step 3 — Configure tenant resolution”](#step-3--configure-tenant-resolution) Add the configuration to `appsettings.json`: ```json { "MultiTenancy": { "IsEnabled": true, "TenantIdClaimType": "tenant_id", "TenantIdHeaderName": "X-Tenant-Id" } } ``` | Option | Default | Description | | -------------------- | --------------- | -------------------------------------------------- | | `IsEnabled` | `true` | Enable or disable tenant resolution | | `TenantIdClaimType` | `"tenant_id"` | JWT claim name containing the tenant ID | | `TenantIdHeaderName` | `"X-Tenant-Id"` | HTTP header name for explicit tenant specification | ### Resolution order [Section titled “Resolution order”](#resolution-order) Two resolvers are registered by default, executed in `Order` sequence (first match wins): 1. **HeaderTenantResolver** (Order 100) — reads the `X-Tenant-Id` header, used for service-to-service calls 2. **JwtClaimTenantResolver** (Order 200) — reads the `tenant_id` claim from the JWT token, used for user-authenticated requests via Keycloak ## Step 4 — Choose an isolation strategy [Section titled “Step 4 — Choose an isolation strategy”](#step-4--choose-an-isolation-strategy) Granit supports three data isolation strategies. Choose based on your security requirements and infrastructure constraints. * Shared database All tenants share a single database. Isolation is enforced through a global `TenantId` query filter on every `IMultiTenant` entity. ```json { "TenantIsolation": { "Strategy": "SharedDatabase" } } ``` ```csharp builder.Services.AddDbContextFactory( options => options.UseNpgsql( builder.Configuration.GetConnectionString("Default"))); ``` This is the default strategy and the simplest to operate. It supports an unlimited number of tenants with minimal infrastructure cost. Warning The `TenantId` query filter is critical for data isolation. Granit applies it automatically via `ApplyGranitConventions()`. Never add manual `HasQueryFilter` calls — they cause duplicates or conflicts. * Schema per tenant Each tenant gets a dedicated PostgreSQL schema. The `search_path` is set unconditionally on every connection. ```json { "TenantIsolation": { "Strategy": "SchemaPerTenant" } } ``` ```csharp builder.Services.AddTenantPerSchemaDbContext( options => options.UseNpgsql(connectionString), schema => schema.Prefix = "tenant_"); ``` Supports up to approximately 1,000 tenants per database. Enables per-tenant backup with `pg_dump -n`. * Database per tenant Each tenant gets a completely separate database. Connection strings are resolved dynamically per tenant. ```json { "TenantIsolation": { "Strategy": "DatabasePerTenant" } } ``` ```csharp builder.Services.AddSingleton(); builder.Services.AddTenantPerDatabaseDbContext( (options, connectionString) => options.UseNpgsql(connectionString)); ``` Provides the strongest isolation — recommended for ISO 27001 environments requiring physical separation. Supports up to approximately 200 tenants (use PgBouncer for higher counts). ### Configurable strategy with unified facade [Section titled “Configurable strategy with unified facade”](#configurable-strategy-with-unified-facade) For applications that need to switch strategies via configuration: ```csharp builder.Services.AddGranitIsolatedDbContext( configureShared: options => options.UseNpgsql( builder.Configuration.GetConnectionString("Default")), configureDatabasePerTenant: (options, connectionString) => options.UseNpgsql(connectionString), configureSchemaPerTenant: options => options.UseNpgsql( builder.Configuration.GetConnectionString("Default")), configureTenantSchema: schema => { schema.NamingConvention = TenantSchemaNamingConvention.TenantId; schema.Prefix = "tenant_"; }); ``` The `IsolatedDbContextFactory` reads the strategy from `appsettings.json` and delegates to the appropriate keyed factory at runtime. ## Step 5 — Use ICurrentTenant in your code [Section titled “Step 5 — Use ICurrentTenant in your code”](#step-5--use-icurrenttenant-in-your-code) ### In a service (constructor injection) [Section titled “In a service (constructor injection)”](#in-a-service-constructor-injection) ```csharp public sealed class PatientService( ICurrentTenant currentTenant, AppDbContext db) { public async Task> GetPatientsAsync( CancellationToken cancellationToken) { if (!currentTenant.IsAvailable) { throw new InvalidOperationException("No tenant context."); } return await db.Patients .ToListAsync(cancellationToken); } } ``` ### In a Wolverine handler (method injection) [Section titled “In a Wolverine handler (method injection)”](#in-a-wolverine-handler-method-injection) ```csharp public static async Task Handle( GetPatientsQuery query, AppDbContext db, ICurrentTenant currentTenant, CancellationToken cancellationToken) { if (!currentTenant.IsAvailable) { return TypedResults.Problem( detail: "Tenant context required.", statusCode: StatusCodes.Status400BadRequest); } var patients = await db.Patients .ToListAsync(cancellationToken); return TypedResults.Ok(patients); } ``` ### Temporary override (background jobs, tests) [Section titled “Temporary override (background jobs, tests)”](#temporary-override-background-jobs-tests) ```csharp using IDisposable scope = currentTenant.Change(tenantId, tenantName); await ProcessTenantDataAsync(); // Previous tenant context is restored when the scope is disposed ``` Use this pattern for `IHostedService` implementations that process multiple tenants sequentially, or in integration tests that simulate tenant-specific requests. ## Step 6 — Add a custom resolver [Section titled “Step 6 — Add a custom resolver”](#step-6--add-a-custom-resolver) To resolve tenants from subdomains or other sources, implement `ITenantResolver`: ```csharp public sealed class SubdomainTenantResolver : ITenantResolver { public int Order => 50; // runs before Header (100) and JWT (200) public Task ResolveAsync( HttpContext context, CancellationToken cancellationToken = default) { var host = context.Request.Host.Host; var subdomain = host.Split('.')[0]; if (Guid.TryParse(subdomain, out var tenantId)) { return Task.FromResult( new TenantInfo(tenantId)); } return Task.FromResult(null); } } ``` Register it in your module: ```csharp services.AddSingleton(); ``` ## ICurrentTenant API reference [Section titled “ICurrentTenant API reference”](#icurrenttenant-api-reference) ```csharp // Namespace: Granit.Core.MultiTenancy (in Granit.Core package) public interface ICurrentTenant { bool IsAvailable { get; } Guid? Id { get; } string? Name { get; } IDisposable Change(Guid? id, string? name = null); } ``` ## Testing [Section titled “Testing”](#testing) ### Mock a tenant in unit tests [Section titled “Mock a tenant in unit tests”](#mock-a-tenant-in-unit-tests) ```csharp var tenant = Substitute.For(); tenant.IsAvailable.Returns(true); tenant.Id.Returns(Guid.NewGuid()); var service = new PatientService(tenant, db); ``` ### Disable in integration tests [Section titled “Disable in integration tests”](#disable-in-integration-tests) ```csharp factory.WithWebHostBuilder(builder => { builder.ConfigureAppConfiguration(config => { config.AddInMemoryCollection(new Dictionary { ["MultiTenancy:IsEnabled"] = "false" }); }); }); ``` ## Next steps [Section titled “Next steps”](#next-steps) * [Configure caching](/guides/configure-caching/) — set up distributed caching with tenant-aware keys * [Create a module](/guides/create-a-module/) — build a tenant-aware module from scratch * [Multi-tenancy reference](/reference/modules/multi-tenancy/) — full API and architecture details # Create a module > Build a self-contained Granit module with DI registration, dependency declaration, and lifecycle hooks A Granit module is a self-contained unit that registers its own services into the DI container. Modules declare their dependencies with `[DependsOn]` and are loaded in topological order — one call in `Program.cs` replaces dozens of manual `Add*()` invocations. ## Prerequisites [Section titled “Prerequisites”](#prerequisites) * A .NET 10 project referencing `Granit.Core` * Familiarity with `IServiceCollection` and dependency injection ## Step 1 — Create the project [Section titled “Step 1 — Create the project”](#step-1--create-the-project) Every module lives in its own project (one project = one NuGet package). ```bash dotnet new classlib -n Granit.Inventory -f net10.0 dotnet add Granit.Inventory package Granit.Core ``` Recommended folder structure: ```text Granit.Inventory/ Domain/ InventoryItem.cs Internal/ InventoryRepository.cs Extensions/ InventoryServiceCollectionExtensions.cs GranitInventoryModule.cs ``` ## Step 2 — Define the module class [Section titled “Step 2 — Define the module class”](#step-2--define-the-module-class) Every module inherits from `GranitModule` and overrides lifecycle methods as needed. ```csharp using Granit.Core.Modularity; using Granit.Inventory.Extensions; namespace Granit.Inventory; public sealed class GranitInventoryModule : GranitModule { public override void ConfigureServices(ServiceConfigurationContext context) { context.Services.AddInventory(context.Configuration); } } ``` The base class provides empty (no-op) implementations for all lifecycle methods. A module that only needs to declare dependencies can inherit without overriding anything. ## Step 3 — Register services [Section titled “Step 3 — Register services”](#step-3--register-services) Create an extension method that encapsulates all DI registrations for the module. ```csharp using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; namespace Granit.Inventory.Extensions; public static class InventoryServiceCollectionExtensions { public static IServiceCollection AddInventory( this IServiceCollection services, IConfiguration configuration) { services.Configure( configuration.GetSection("Inventory")); services.AddScoped(); services.AddScoped(); return services; } } ``` ## Step 4 — Declare dependencies [Section titled “Step 4 — Declare dependencies”](#step-4--declare-dependencies) Use `[DependsOn]` to declare which modules must be loaded before yours. Dependencies are resolved transitively and deduplicated automatically. ```csharp using Granit.Core.Modularity; using Granit.Persistence; using Granit.Security; namespace Granit.Inventory; [DependsOn(typeof(GranitPersistenceModule))] [DependsOn(typeof(GranitSecurityModule))] public sealed class GranitInventoryModule : GranitModule { public override void ConfigureServices(ServiceConfigurationContext context) { context.Services.AddInventory(context.Configuration); } } ``` You can also combine multiple types in a single attribute: ```csharp [DependsOn( typeof(GranitPersistenceModule), typeof(GranitSecurityModule))] public sealed class GranitInventoryModule : GranitModule { } ``` ## Step 5 — Use lifecycle hooks [Section titled “Step 5 — Use lifecycle hooks”](#step-5--use-lifecycle-hooks) The module system provides two lifecycle phases: | Phase | Method | When | | -------------------------- | ------------------------------------------------------------------ | ------------------------------- | | Service registration | `ConfigureServices` / `ConfigureServicesAsync` | Before `Build()` | | Application initialization | `OnApplicationInitialization` / `OnApplicationInitializationAsync` | After `Build()`, before `Run()` | * Sync (standard) ```csharp public sealed class GranitInventoryModule : GranitModule { public override void ConfigureServices(ServiceConfigurationContext context) { context.Services.AddInventory(context.Configuration); } public override void OnApplicationInitialization( ApplicationInitializationContext context) { var logger = context.ServiceProvider .GetRequiredService>(); logger.LogInformation("Inventory module initialized"); } } ``` * Async (remote config) ```csharp public sealed class GranitInventoryModule : GranitModule { public override async Task ConfigureServicesAsync( ServiceConfigurationContext context) { var remoteConfig = await FetchRemoteConfigAsync( context.Configuration); context.Services.Configure( o => o.WarehouseId = remoteConfig.WarehouseId); } } ``` ## Step 6 — Conditional loading with IsEnabled [Section titled “Step 6 — Conditional loading with IsEnabled”](#step-6--conditional-loading-with-isenabled) A module can disable itself based on configuration or environment: ```csharp public sealed class GranitInventoryModule : GranitModule { public override bool IsEnabled(ServiceConfigurationContext context) { var options = context.Configuration .GetSection("Inventory").Get(); return options?.IsEnabled ?? false; } public override void ConfigureServices(ServiceConfigurationContext context) { context.Services.AddInventory(context.Configuration); } } ``` A disabled module remains in the dependency graph (its dependents still work) but its `ConfigureServices` and `OnApplicationInitialization` methods are not called. It appears as `[DISABLED]` in the startup logs. ## Step 7 — Wire up in Program.cs [Section titled “Step 7 — Wire up in Program.cs”](#step-7--wire-up-in-programcs) ```csharp using Granit.Core.Extensions; using MyApp.Host; var builder = WebApplication.CreateBuilder(args); await builder.AddGranitAsync(); var app = builder.Build(); await app.UseGranitAsync(); app.Run(); ``` Your application host module references the inventory module: ```csharp [DependsOn(typeof(GranitInventoryModule))] public sealed class MyAppHostModule : GranitModule { public override void ConfigureServices(ServiceConfigurationContext context) { context.Services.AddHealthChecks(); } } ``` ## ServiceConfigurationContext reference [Section titled “ServiceConfigurationContext reference”](#serviceconfigurationcontext-reference) | Property | Type | Description | | ------------------ | ------------------------------ | -------------------------------------------------------- | | `Services` | `IServiceCollection` | DI registration | | `Configuration` | `IConfiguration` | App configuration (appsettings, env vars) | | `Builder` | `IHostApplicationBuilder` | Full builder (needed by some modules like Observability) | | `ModuleAssemblies` | `IReadOnlyList` | All loaded module assemblies in topological order | | `Items` | `IDictionary` | Shared state for inter-module communication | ## Next steps [Section titled “Next steps”](#next-steps) * [Add an endpoint](/guides/add-an-endpoint/) — expose your module via Minimal API * [Configure multi-tenancy](/guides/configure-multi-tenancy/) — add tenant isolation to your module * [Granit.Core reference](/reference/modules/core/) — module system internals # Create document templates > Build Scriban templates for HTML emails, PDF invoices, and Excel reports with the Granit templating and document generation pipeline Granit.Templating and Granit.DocumentGeneration provide a complete pipeline to render data-driven documents. Templates are written in Scriban (text) or stored as XLSX (Excel), enriched with global context variables, and rendered to HTML, PDF, or native Excel files. ## Prerequisites [Section titled “Prerequisites”](#prerequisites) * A .NET 10 project with Granit module system configured * A PostgreSQL (or other EF Core-supported) database for the template store * For PDF generation: Docker or a Linux host with Chromium installed ## Step 1 — Install packages [Section titled “Step 1 — Install packages”](#step-1--install-packages) ```bash # Core templating with Scriban engine dotnet add package Granit.Templating.Scriban # EF Core store for template management dotnet add package Granit.Templating.EntityFrameworkCore # Document generation facade dotnet add package Granit.DocumentGeneration # PDF renderer (HTML to PDF via Chromium) dotnet add package Granit.DocumentGeneration.Pdf # Excel renderer (native XLSX via ClosedXML) dotnet add package Granit.DocumentGeneration.Excel ``` ## Step 2 — Declare modules [Section titled “Step 2 — Declare modules”](#step-2--declare-modules) ```csharp using Granit.Core.Modularity; using Granit.DocumentGeneration; using Granit.DocumentGeneration.Excel; using Granit.Templating.EntityFrameworkCore; using Granit.Templating.Scriban; [DependsOn( typeof(GranitTemplatingScribanModule), typeof(GranitTemplatingEntityFrameworkCoreModule), typeof(GranitDocumentGenerationModule), typeof(GranitDocumentGenerationExcelModule))] public sealed class MyAppModule : GranitModule { } ``` ## Step 3 — Register services [Section titled “Step 3 — Register services”](#step-3--register-services) ```csharp // Scriban engine (ITemplateEngine + global contexts now.* and context.*) builder.Services.AddGranitTemplatingWithScriban(); // Excel ClosedXML engine (additive -- both engines coexist) builder.Services.AddGranitDocumentGenerationExcel(); // EF Core store (IDocumentTemplateStoreReader/Writer + HybridCache L1) builder.AddGranitTemplatingEntityFrameworkCore(options => options.UseNpgsql(builder.Configuration.GetConnectionString("Default"))); // Document generation facade builder.Services.AddGranitDocumentGeneration(); ``` ## Step 4 — Define a template type [Section titled “Step 4 — Define a template type”](#step-4--define-a-template-type) Each template type is a strongly-typed class that binds a name to a data model. * Text template (email) ```csharp public sealed record WelcomeEmailData(string FirstName, string ActivationUrl); public sealed class WelcomeEmailType : TextTemplateType { public override string Name => "Notifications.WelcomeEmail"; } ``` * Document template (PDF) ```csharp public sealed record InvoiceData( string InvoiceNumber, string CustomerName, decimal TotalAmount, IReadOnlyList Lines, string? QrCodeSvg = null); public sealed record InvoiceLineData( string Description, int Quantity, decimal UnitPrice); public sealed class InvoiceTemplateType : DocumentTemplateType { public override string Name => "Billing.Invoice"; // DefaultFormat = DocumentFormat.Pdf } ``` * Excel template ```csharp public sealed record ReportData( string ReportTitle, DateOnly GeneratedDate, IReadOnlyList Rows); public sealed class ExcelReportTemplateType : DocumentTemplateType { public override string Name => "Reports.Monthly"; public override DocumentFormat DefaultFormat => DocumentFormat.Excel; } ``` ## Step 5 — Write a Scriban template [Section titled “Step 5 — Write a Scriban template”](#step-5--write-a-scriban-template) The Scriban engine exposes your `TData` under the `model` variable with snake\_case property names. ```html

Invoice {{ model.invoice_number }}

Customer: {{ model.customer_name }}

Date: {{ now.date }}

{{ for line in model.lines }} {{ end }}
DescriptionQtyUnit price
{{ line.description }} {{ line.quantity }} {{ line.unit_price }}

Total: {{ model.total_amount }}

``` ### Global context variables [Section titled “Global context variables”](#global-context-variables) These variables are available in every template without configuration: | Variable | Value | Example | | ---------------------------- | ------------------------ | ----------------------------- | | `{{ now.date }}` | Local date | `27/02/2026` | | `{{ now.datetime }}` | Local date and time | `27/02/2026 14:35` | | `{{ now.iso }}` | ISO 8601 format | `2026-02-27T14:35:00+00:00` | | `{{ now.year }}` | Year | `2026` | | `{{ context.culture }}` | Current culture (BCP 47) | `fr-BE` | | `{{ context.culture_name }}` | Culture display name | `French (Belgium)` | | `{{ context.tenant_id }}` | Current tenant ID | `3fa85f64-...` or empty | | `{{ context.tenant_name }}` | Tenant name | `Hospital Saint-Luc` or empty | Warning Templates run in a sandboxed `TemplateContext` with `EnableRelaxedMemberAccess = false`. No I/O, network, or .NET reflection access is possible from a template. Never expose PII in `ITemplateGlobalContext` — sensitive data (patient name, social security number) must transit exclusively via `TData`. ## Step 6 — Store and publish templates [Section titled “Step 6 — Store and publish templates”](#step-6--store-and-publish-templates) Templates follow a lifecycle: **Draft** -> **Published** -> **Archived**. Only published templates are used by the rendering pipeline. ```csharp public sealed class TemplateAdminService( IDocumentTemplateStoreReader storeReader, IDocumentTemplateStoreWriter storeWriter) { public async Task CreateAndPublishAsync( string html, CancellationToken cancellationToken) { var key = new TemplateKey("Billing.Invoice", Culture: "en"); // Save a draft await storeWriter.SaveDraftAsync( key, html, "text/html", "admin@example.com", cancellationToken); // Publish the draft (invalidates HybridCache) await storeWriter.PublishAsync( key, "admin@example.com", cancellationToken); } public async Task> GetAuditHistoryAsync( TemplateKey key, CancellationToken cancellationToken) { // ISO 27001 audit trail -- archived revisions are never deleted return await storeReader.GetHistoryAsync(key, cancellationToken); } } ``` ### Embedded templates (fallback) [Section titled “Embedded templates (fallback)”](#embedded-templates-fallback) For templates shipped with your assembly, mark them as embedded resources: ```xml ``` Register them in DI: ```csharp builder.Services.AddEmbeddedTemplates(typeof(MyAppModule).Assembly); ``` ## Step 7 — Render documents [Section titled “Step 7 — Render documents”](#step-7--render-documents) ### Render an HTML email [Section titled “Render an HTML email”](#render-an-html-email) ```csharp public sealed class WelcomeEmailService(ITextTemplateRenderer renderer) { public async Task GetHtmlAsync( WelcomeEmailData data, CancellationToken cancellationToken) { RenderedTextResult result = await renderer.RenderAsync( new WelcomeEmailType(), data, cancellationToken); return result.Html; } } ``` ### Generate a PDF [Section titled “Generate a PDF”](#generate-a-pdf) ```csharp public sealed class InvoiceService(IDocumentGenerator generator) { public async Task GenerateInvoiceAsync( InvoiceData data, CancellationToken cancellationToken) { return await generator.GenerateAsync( new InvoiceTemplateType(), data, cancellationToken: cancellationToken); } } ``` ### Generate a native Excel file [Section titled “Generate a native Excel file”](#generate-a-native-excel-file) The template is an `.xlsx` file stored as base64 in the store with the Excel MIME type. The `ClosedXmlTemplateEngine` is selected automatically. ```csharp public sealed class ExcelReportService(IDocumentGenerator generator) { public async Task GenerateReportAsync( ReportData data, CancellationToken cancellationToken) { return await generator.GenerateAsync( new ExcelReportTemplateType(), data, cancellationToken: cancellationToken); } } ``` ## Enriching template data [Section titled “Enriching template data”](#enriching-template-data) `ITemplateDataEnricher` lets you add computed data (QR codes, remote blob URLs, signatures) before rendering. Enrichers return a new `TData` via `record with` — the model is immutable. ```csharp public sealed class QrCodeEnricher : ITemplateDataEnricher { public int Order => 10; public Task EnrichAsync( InvoiceData data, CancellationToken cancellationToken = default) { var svg = QrCodeGenerator.Generate(data.InvoiceNumber); return Task.FromResult(data with { QrCodeSvg = svg }); } } ``` Register enrichers in DI: ```csharp builder.Services.AddTemplateDataEnricher(); ``` ## Custom global contexts [Section titled “Custom global contexts”](#custom-global-contexts) Add your own global variables available in every template: ```csharp public sealed class CompanyGlobalContext : ITemplateGlobalContext { public string ContextName => "company"; public object Resolve() => new { name = "Acme Corp", vat_number = "BE0123456789" }; } ``` Register and use in templates as `{{ company.name }}`: ```csharp builder.Services.AddTemplateGlobalContext(); ``` ## PDF configuration [Section titled “PDF configuration”](#pdf-configuration) Configure PDF rendering options in `appsettings.json`: ```json { "DocumentGeneration": { "Pdf": { "PaperFormat": "A4", "Landscape": false, "MarginTop": "10mm", "MarginBottom": "10mm", "MarginLeft": "10mm", "MarginRight": "10mm", "PrintBackground": true, "MaxConcurrentPages": 4, "ChromiumExecutablePath": null } } } ``` For Docker deployments, install Chromium system-wide and set `ChromiumExecutablePath` to `/usr/bin/chromium`. ## Next steps [Section titled “Next steps”](#next-steps) * [Implement a workflow](/guides/implement-workflow/) to add approval cycles to template publication (Draft -> PendingReview -> Published) * [Set up localization](/guides/set-up-localization/) for culture-specific template resolution * [Templating reference](/reference/modules/templating/) for the complete API surface # Encrypt sensitive data > Protect PII and sensitive fields at rest using Vault Transit encryption, AES-256 field-level encryption, and encrypted caching Granit provides two encryption layers — field-level encryption via `IStringEncryptionService` and Vault Transit encryption via `ITransitEncryptionService` — so sensitive data (national ID numbers, health records, API keys) is never stored in plaintext. ## Prerequisites [Section titled “Prerequisites”](#prerequisites) * A .NET 10 project with `Granit.Core` * `Granit.Encryption` for AES-256 field-level encryption * `Granit.Vault` for vault abstractions (`ITransitEncryptionService`, `IDatabaseCredentialProvider`) * `Granit.Vault.HashiCorp` for HashiCorp Vault Transit encryption (production) * `Granit.Vault.Azure` for Azure Key Vault encryption (production, Azure environments) * A running HashiCorp Vault or Azure Key Vault instance (production only) ## Step 1 — Install the packages [Section titled “Step 1 — Install the packages”](#step-1--install-the-packages) * AES-256 (local key) ```bash dotnet add package Granit.Encryption ``` * Vault Transit (production) ```bash dotnet add package Granit.Vault.HashiCorp ``` `Granit.Vault.HashiCorp` depends on `Granit.Vault` and `Granit.Encryption` — all are installed automatically. * Azure Key Vault (production) ```bash dotnet add package Granit.Vault.Azure ``` `Granit.Vault.Azure` depends on `Granit.Vault` and `Granit.Encryption` — all are installed automatically. Uses `DefaultAzureCredential` (Managed Identity in production, `az login` locally). ## Step 2 — Configure encryption [Section titled “Step 2 — Configure encryption”](#step-2--configure-encryption) ### AES-256 provider (development / non-Vault environments) [Section titled “AES-256 provider (development / non-Vault environments)”](#aes-256-provider-development--non-vault-environments) Add the module dependency and configure the passphrase: ```csharp [DependsOn(typeof(GranitEncryptionModule))] public sealed class AppModule : GranitModule { } ``` ```json { "Encryption": { "PassPhrase": "", "ProviderName": "Aes" } } ``` Warning Never store `PassPhrase` in plaintext in `appsettings.json`. Use environment variables, Vault configuration provider, or a secrets manager. ISO 27001 requires encryption keys to be rotated and stored securely. ### Vault Transit provider (production) [Section titled “Vault Transit provider (production)”](#vault-transit-provider-production) ```csharp [DependsOn(typeof(GranitVaultHashiCorpModule))] public sealed class AppModule : GranitModule { } ``` ```json { "Vault": { "Address": "https://vault.internal:8200", "AuthMethod": "Kubernetes", "KubernetesRole": "my-backend", "TransitMountPoint": "transit" }, "Encryption": { "ProviderName": "Vault" } } ``` The `ProviderName` setting selects the active provider: | Provider | `ProviderName` | Latency | When to use | | --------------------------------------- | ----------------- | -------- | -------------------------------------------- | | `AesStringEncryptionProvider` | `"Aes"` | < 1 ms | Settings, cache, high-frequency operations | | `VaultStringEncryptionProvider` | `"Vault"` | 10—20 ms | High-security operations, HashiCorp Vault | | `AzureKeyVaultStringEncryptionProvider` | `"AzureKeyVault"` | 10—30 ms | High-security operations, Azure environments | ## Step 3 — Encrypt fields before persistence [Section titled “Step 3 — Encrypt fields before persistence”](#step-3--encrypt-fields-before-persistence) Inject `IStringEncryptionService` to encrypt and decrypt individual fields: ```csharp using Granit.Encryption; namespace MyApp.Services; public sealed class PatientService( AppDbContext db, IStringEncryptionService encryption) { public async Task CreateAsync( string firstName, string lastName, string nirNumber, CancellationToken cancellationToken) { var patient = new Patient { FirstName = firstName, LastName = lastName, NirNumberEncrypted = encryption.Encrypt(nirNumber) }; db.Patients.Add(patient); await db.SaveChangesAsync(cancellationToken); return patient.Id; } public string? DecryptNir(string cipherText) => encryption.Decrypt(cipherText); } ``` The AES-256-CBC provider generates a random IV for every encryption call (`RandomNumberGenerator.GetBytes(16)`). The ciphertext format is `Base64(IV[16] || CipherText[N])` — the IV is extracted automatically during decryption. ## Step 4 — Use Vault Transit for high-security operations [Section titled “Step 4 — Use Vault Transit for high-security operations”](#step-4--use-vault-transit-for-high-security-operations) For data that requires centralized key management (encryption keys never leave Vault), use `ITransitEncryptionService`: ```csharp using Granit.Vault; namespace MyApp.Services; public sealed class HealthRecordService(ITransitEncryptionService transit) { public async Task EncryptDiagnosisAsync( string diagnosis, CancellationToken cancellationToken) { // Key name corresponds to a Transit key in Vault var encrypted = await transit.EncryptAsync( "health-records", diagnosis, cancellationToken); // Returns "vault:v1:..." -- version-tagged ciphertext return encrypted; } public async Task DecryptDiagnosisAsync( string cipherText, CancellationToken cancellationToken) => await transit.DecryptAsync( "health-records", cipherText, cancellationToken); } ``` ## Step 5 — Encrypt cached values [Section titled “Step 5 — Encrypt cached values”](#step-5--encrypt-cached-values) For data stored in Redis or another distributed cache, use the `[CacheEncrypted]` attribute to enable transparent AES-256 encryption of cached values: ```csharp using Granit.Caching; namespace MyApp.Caching; [CacheEncrypted] public sealed class PatientCacheItem { public Guid Id { get; set; } public string FirstName { get; set; } = string.Empty; public string LastName { get; set; } = string.Empty; } ``` The `ICacheService` automatically encrypts before writing to Redis and decrypts after reading. Even if the cache backend is compromised, the data is unreadable. ```csharp public sealed class PatientCacheService( ICacheService cache, AppDbContext db) { public async Task GetAsync( Guid id, CancellationToken cancellationToken) { var cacheKey = $"patient:{id}"; var cached = await cache.GetAsync(cacheKey, cancellationToken); if (cached is not null) { return cached; } var patient = await db.Patients.FindAsync([id], cancellationToken); if (patient is null) { return null; } var item = new PatientCacheItem { Id = patient.Id, FirstName = patient.FirstName, LastName = patient.LastName }; await cache.SetAsync(cacheKey, item, cancellationToken); return item; } } ``` ## Step 6 — Encrypt settings automatically [Section titled “Step 6 — Encrypt settings automatically”](#step-6--encrypt-settings-automatically) Settings declared with `IsEncrypted = true` are encrypted at rest in the database. The cache stores plaintext to avoid double encryption (Redis is already protected by `AesCacheValueEncryptor`): ```csharp public sealed class AppSettingDefinitionProvider : ISettingDefinitionProvider { public void Define(ISettingDefinitionContext context) { context.Add(new SettingDefinition("Integrations.ExternalApiKey") { IsEncrypted = true, IsVisibleToClients = false, Providers = { GlobalSettingValueProvider.ProviderName } }); } } ``` ## Encryption architecture summary [Section titled “Encryption architecture summary”](#encryption-architecture-summary) | Layer | Mechanism | Scope | Key management | | ----------------------------- | --------------------------- | ---------------------------- | ---------------------------- | | Field-level (AES) | `IStringEncryptionService` | Individual entity properties | Passphrase via PBKDF2 | | Field-level (Vault Transit) | `ITransitEncryptionService` | Individual entity properties | Vault-managed, auto-rotation | | Field-level (Azure Key Vault) | `IStringEncryptionService` | Individual entity properties | Azure Key Vault RSA-OAEP-256 | | Cache | `[CacheEncrypted]` | Entire cached object | Local AES-256 key | | Settings | `IsEncrypted = true` | Setting values in database | `IStringEncryptionService` | ## Next steps [Section titled “Next steps”](#next-steps) * [Manage application settings](/guides/manage-application-settings/) — encrypted settings with cascading resolution * [Vault and Encryption reference](/reference/modules/vault-encryption/) — full API and configuration reference * [Observability reference](/reference/modules/observability/) — monitor encryption operations with OpenTelemetry # Set up end-to-end tracing > Configure OpenTelemetry with W3C Trace Context propagation across HTTP requests, Wolverine handlers, and EF Core queries for visualization in Grafana Tempo Granit.Observability configures Serilog (structured logs) and OpenTelemetry (traces and metrics) with OTLP export to the LGTM stack (Loki/Grafana/Tempo/Mimir). Combined with Granit.Wolverine’s trace context propagation, you get a single trace spanning HTTP requests, asynchronous message handlers, and database queries. ## Prerequisites [Section titled “Prerequisites”](#prerequisites) * A .NET 10 project with Granit module system configured * An OpenTelemetry Collector (or Grafana Alloy) reachable via gRPC * Grafana with Tempo (traces) and Loki (logs) data sources configured ## Step 1 — Install packages [Section titled “Step 1 — Install packages”](#step-1--install-packages) ```bash dotnet add package Granit.Observability ``` If you use Wolverine messaging, the `Granit.Wolverine` package provides trace context propagation automatically — no additional packages are needed. ## Step 2 — Configure the OTLP endpoint [Section titled “Step 2 — Configure the OTLP endpoint”](#step-2--configure-the-otlp-endpoint) Add the `Observability` section to `appsettings.json`: ```json { "Observability": { "ServiceName": "my-backend", "ServiceVersion": "1.0.0", "OtlpEndpoint": "http://otel-collector:4317", "ServiceNamespace": "my-company", "Environment": "production", "EnableTracing": true, "EnableMetrics": true } } ``` | Property | Type | Default | Description | | ------------------ | -------- | ----------------------- | -------------------------------------------- | | `ServiceName` | `string` | `unknown-service` | Identifies the service in Tempo and Loki | | `ServiceVersion` | `string` | `0.0.0` | Displayed in trace attributes | | `OtlpEndpoint` | `string` | `http://localhost:4317` | gRPC endpoint of the OpenTelemetry Collector | | `ServiceNamespace` | `string` | `my-company` | Groups related services in dashboards | | `Environment` | `string` | `development` | Deployment environment tag | | `EnableTracing` | `bool` | `true` | Enable/disable distributed tracing | | `EnableMetrics` | `bool` | `true` | Enable/disable metrics export | ## Step 3 — Register observability [Section titled “Step 3 — Register observability”](#step-3--register-observability) * Module system (recommended) With the Granit module system, `GranitObservabilityModule` is loaded automatically via `AddGranit()`. No additional code is needed. * Direct registration ```csharp builder.AddGranitObservability(); ``` This single call configures: * **Serilog** with console sink (development) and OpenTelemetry sink (OTLP to Loki) * **OpenTelemetry tracing** with ASP.NET Core, HttpClient, and EF Core instrumentation * **OpenTelemetry metrics** with ASP.NET Core and HttpClient instrumentation * **Resource attributes**: `service.name`, `service.version`, `service.namespace`, `deployment.environment` ## Step 4 — Configure log levels [Section titled “Step 4 — Configure log levels”](#step-4--configure-log-levels) Serilog reads additional configuration from `appsettings.json`: ```json { "Serilog": { "MinimumLevel": { "Default": "Information", "Override": { "Microsoft.AspNetCore": "Warning", "Microsoft.EntityFrameworkCore": "Warning" } } } } ``` ## Automatic instrumentations [Section titled “Automatic instrumentations”](#automatic-instrumentations) ### Traces [Section titled “Traces”](#traces) | Instrumentation | Data collected | | --------------------- | ------------------------------------------ | | ASP.NET Core | Incoming HTTP requests (except `/healthz`) | | HttpClient | Outgoing HTTP requests | | Entity Framework Core | SQL queries | Exceptions are recorded automatically (`RecordException = true`). ### Metrics [Section titled “Metrics”](#metrics) | Instrumentation | Metrics collected | | --------------- | -------------------------------- | | ASP.NET Core | Request duration, response codes | | HttpClient | Outgoing call duration | ## Correlating across Wolverine (async boundaries) [Section titled “Correlating across Wolverine (async boundaries)”](#correlating-across-wolverine-async-boundaries) The main challenge in distributed tracing is maintaining trace correlation across asynchronous boundaries. When an HTTP request publishes a Wolverine message via the transactional outbox, the worker that processes that message seconds (or hours) later must appear under the **same trace** in Tempo. ### How it works [Section titled “How it works”](#how-it-works) `Granit.Wolverine` handles this automatically: 1. **Sender side** — `OutgoingContextMiddleware` captures the current `Activity.Current.Id` (W3C `traceparent`) and injects it into the Wolverine envelope headers along with `TenantId` and `UserId` 2. **Receiver side** — `TraceContextBehavior` reads the `traceparent` header, parses it with `ActivityContext.TryParse()`, and starts a bridge activity named `wolverine.message.handle` with the original trace as parent The result: every span created during handler execution (EF Core queries, HTTP calls, custom spans) appears as a child of the original HTTP request trace. ### Execution pipeline [Section titled “Execution pipeline”](#execution-pipeline) ```text [Incoming message] -> TenantContextBehavior.Before() -- restores ICurrentTenant -> UserContextBehavior.Before() -- restores ICurrentUserService -> TraceContextBehavior.Before() -- restores trace-id (Activity bridge) -> [Handler execution] -> TraceContextBehavior.After() -- disposes the bridge activity -> UserContextBehavior.After() -> TenantContextBehavior.After() ``` ### Complete example [Section titled “Complete example”](#complete-example) **HTTP endpoint** — publishes an event: ```csharp internal static class OrderEndpoints { internal static async Task PlaceOrderAsync( PlaceOrderRequest request, IMessageBus bus, ILogger logger, CancellationToken cancellationToken) { logger.LogInformation( "Placing order for {ProductId}, quantity {Quantity}", request.ProductId, request.Quantity); await bus.PublishAsync(new OrderPlacedEvent( request.ProductId, request.Quantity)); return TypedResults.Accepted(value: (string?)null); } } public sealed record PlaceOrderRequest(Guid ProductId, int Quantity); public sealed record OrderPlacedEvent(Guid ProductId, int Quantity); ``` **Wolverine handler** — processes the event with the same trace-id: ```csharp public static class OrderPlacedEventHandler { public static async Task HandleAsync( OrderPlacedEvent evt, AppDbContext db, ILogger logger, CancellationToken cancellationToken) { // This log shares the same TraceId as the HTTP request logger.LogInformation( "Processing order for {ProductId}, quantity {Quantity}", evt.ProductId, evt.Quantity); var order = new Order { ProductId = evt.ProductId, Quantity = evt.Quantity, Status = OrderStatus.Processing }; db.Orders.Add(order); await db.SaveChangesAsync(cancellationToken); } } ``` ## Adding custom spans to your modules [Section titled “Adding custom spans to your modules”](#adding-custom-spans-to-your-modules) Each Granit module that performs significant I/O declares a dedicated `ActivitySource`. Follow this pattern for your own modules: ### 1. Create an ActivitySource [Section titled “1. Create an ActivitySource”](#1-create-an-activitysource) ```csharp using System.Diagnostics; internal static class InventoryActivitySource { internal const string Name = "MyApp.Inventory"; internal static readonly ActivitySource Source = new(Name); internal const string CheckStock = "inventory.check-stock"; } ``` ### 2. Register it [Section titled “2. Register it”](#2-register-it) ```csharp using Granit.Observability; public static IServiceCollection AddInventory(this IServiceCollection services) { GranitActivitySourceRegistry.Register(InventoryActivitySource.Name); // ... other registrations return services; } ``` ### 3. Instrument I/O operations [Section titled “3. Instrument I/O operations”](#3-instrument-io-operations) ```csharp public async Task CheckStockAsync( Guid productId, CancellationToken cancellationToken) { using var activity = InventoryActivitySource.Source.StartActivity( InventoryActivitySource.CheckStock); activity?.SetTag("inventory.product_id", productId.ToString()); var stock = await _dbContext.Stock .Where(s => s.ProductId == productId) .Select(s => s.Quantity) .FirstOrDefaultAsync(cancellationToken); activity?.SetTag("inventory.stock_level", stock); return stock; } ``` ### Registered ActivitySources in Granit [Section titled “Registered ActivitySources in Granit”](#registered-activitysources-in-granit) | Source | Module | Spans | | -------------------------- | ------------------------ | ----------------------------------------------------------------------------- | | `Granit.Wolverine` | Granit.Wolverine | `wolverine.message.handle` | | `Granit.Webhooks` | Granit.Webhooks | `webhooks.deliver`, `webhooks.fanout` | | `Granit.Notifications` | Granit.Notifications | `notifications.deliver`, `notifications.fanout` | | `Granit.BackgroundJobs` | Granit.BackgroundJobs | `backgroundjobs.trigger` | | `Granit.BlobStorage.S3` | Granit.BlobStorage.S3 | `blobstorage.upload-ticket`, `blobstorage.download-url`, `blobstorage.delete` | | `Granit.Identity.Keycloak` | Granit.Identity.Keycloak | `identity.keycloak.*` | ## Viewing traces in Grafana [Section titled “Viewing traces in Grafana”](#viewing-traces-in-grafana) ### Grafana Tempo [Section titled “Grafana Tempo”](#grafana-tempo) Search by trace ID to see the complete waterfall: ```text POST /api/orders (12ms) |-- PublishAsync: OrderPlacedEvent (2ms) +-- [Wolverine] OrderPlacedEventHandler (45ms) |-- EF Core: INSERT INTO orders (8ms) +-- Commit transaction (3ms) ``` ### Grafana Loki [Section titled “Grafana Loki”](#grafana-loki) Filter logs by `TraceId` to see all log entries from a single operation: ```logql {service_name="my-backend"} | json | TraceId = "abc-123" ``` Every Serilog log entry includes `TraceId` and `SpanId`. In Grafana, clicking a log entry opens the corresponding trace in Tempo. ## OTLP pipeline [Section titled “OTLP pipeline”](#otlp-pipeline) The typical production pipeline: ```text Application -> OTLP gRPC -> OpenTelemetry Collector -> Tempo (traces) -> Mimir (metrics) -> Loki (logs) ``` ## Edge cases [Section titled “Edge cases”](#edge-cases) | Situation | Behavior | | --------------------------------------------- | ----------------------------------------------- | | No `traceparent` header in Wolverine envelope | No-op — handler runs normally | | Malformed `traceparent` header | Warning log + no-op (no exception) | | `Granit.Wolverine` source not listened | `StartActivity()` returns `null` — silent no-op | | No active Activity at publish time | `traceparent` header is absent | ## Next steps [Section titled “Next steps”](#next-steps) * [Configure blob storage](/guides/configure-blob-storage/) — blob storage operations emit their own ActivitySource spans * [Implement a workflow](/guides/implement-workflow/) to trace workflow transitions across async handlers * [Observability reference](/reference/modules/observability/) for the complete configuration options # Frontend Quick Start > Integrate granit-front into a Vite/React application in 5 steps. This guide shows how to integrate `granit-front` into an existing Vite/React application. By the end, the application will have a structured logger, an authenticated HTTP client, and a typed Keycloak authentication context. ## Target architecture [Section titled “Target architecture”](#target-architecture) ``` flowchart LR APP[Vite/React App] APP --> AUTH["@granit/react-authentication"] APP --> API["@granit/api-client"] APP --> UTILS["@granit/utils"] APP --> LOGGER["@granit/logger"] AUTH --> API ``` ## Prerequisites [Section titled “Prerequisites”](#prerequisites) * **Node.js** 24+ * **pnpm** 10+ * A running Keycloak instance (or any OIDC provider) * Familiarity with TypeScript and React ## Integration steps [Section titled “Integration steps”](#integration-steps) 1. **Clone the repository** Clone `granit-front` next to the consuming application: ```text workspace/ ├── granit-front/ ← framework monorepo └── my-app/ ← your Vite/React application ``` ```bash cd workspace git clone granit-front cd granit-front && pnpm install ``` 2. **Declare dependencies** In the application’s `package.json`, add packages using the `link:` protocol: ```json { "dependencies": { "@granit/logger": "link:../granit-front/packages/@granit/logger", "@granit/utils": "link:../granit-front/packages/@granit/utils", "@granit/api-client": "link:../granit-front/packages/@granit/api-client", "@granit/react-authentication": "link:../granit-front/packages/@granit/react-authentication" } } ``` Then install peer dependencies: ```bash pnpm add axios clsx tailwind-merge date-fns keycloak-js ``` 3. **Configure Vite and TypeScript** * vite.config.ts Add aliases so Vite resolves TypeScript sources directly (source-direct pattern): ```typescript import path from 'path'; import { defineConfig } from 'vite'; const GRANIT = path.resolve(__dirname, '../granit-front/packages/@granit'); export default defineConfig({ resolve: { alias: { '@granit/logger': path.join(GRANIT, 'logger/src/index.ts'), '@granit/utils': path.join(GRANIT, 'utils/src/index.ts'), '@granit/api-client': path.join(GRANIT, 'api-client/src/index.ts'), '@granit/react-authentication': path.join(GRANIT, 'react-authentication/src/index.ts'), }, }, }); ``` * tsconfig.json Add matching `paths` in **every** tsconfig (`tsconfig.app.json`, `tsconfig.test.json`, `tsconfig.storybook.json`): ```json { "compilerOptions": { "paths": { "@granit/logger": ["../granit-front/packages/@granit/logger/src/index.ts"], "@granit/utils": ["../granit-front/packages/@granit/utils/src/index.ts"], "@granit/api-client": ["../granit-front/packages/@granit/api-client/src/index.ts"], "@granit/react-authentication": [ "../granit-front/packages/@granit/react-authentication/src/index.ts" ] } } } ``` 4. **Create the logger and API client** * src/lib/logger.ts ```typescript import { createLogger } from '@granit/logger'; export const logger = createLogger('[MyApp]'); ``` * src/lib/api.ts ```typescript import { createApiClient } from '@granit/api-client'; export const api = createApiClient({ baseURL: import.meta.env.VITE_API_URL, timeout: 15_000, }); ``` The Bearer token is automatically injected by `@granit/react-authentication` once configured in the next step. 5. **Configure Keycloak authentication** Create the authentication context and provider: src/auth/auth-context.ts ```typescript import { createAuthContext } from '@granit/react-authentication'; import type { BaseAuthContextType } from '@granit/authentication'; interface AuthContextType extends BaseAuthContextType { // Add application-specific fields if needed } export const { AuthContext, useAuth } = createAuthContext(); ``` src/auth/AuthProvider.tsx ```tsx import { useKeycloakInit } from '@granit/react-authentication'; import { AuthContext } from './auth-context'; export function AuthProvider({ children }: { children: React.ReactNode }) { const auth = useKeycloakInit({ url: import.meta.env.VITE_KEYCLOAK_URL, realm: import.meta.env.VITE_KEYCLOAK_REALM, clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID, }); if (auth.loading) return
Loading…
; return ( {children} ); } ``` Wrap the application: src/main.tsx ```tsx import { AuthProvider } from './auth/AuthProvider'; import { App } from './App'; createRoot(document.getElementById('root')!).render( ); ``` ## Verify the setup [Section titled “Verify the setup”](#verify-the-setup) ```bash # Verify granit-front is healthy cd ../granit-front pnpm lint && pnpm tsc && pnpm test run # Start the application cd ../my-app pnpm dev ``` ## Usage example [Section titled “Usage example”](#usage-example) ```tsx import { useAuth } from './auth/auth-context'; import { logger } from '@/lib/logger'; function UserProfile() { const { user, logout } = useAuth(); logger.info('Profile displayed', { userId: user?.sub }); return (

{user?.name}

); } ``` ## How source resolution works [Section titled “How source resolution works”](#how-source-resolution-works) ```text import { useQueryEndpoint } from '@granit/querying' → Vite alias → packages/@granit/querying/src/index.ts → TypeScript source → transpiled on the fly by Vite → no dist/, no intermediate build ``` This **source-direct** approach provides instant HMR on framework code, zero build watch overhead, direct source maps, and frictionless refactoring across framework and application boundaries. ## See also [Section titled “See also”](#see-also) * [Frontend SDK Overview](/reference/frontend/) — package reference * [Frontend Testing](/guides/frontend-testing/) — test conventions and patterns * [Frontend Troubleshooting](/guides/frontend-troubleshooting/) — common issues and solutions # Frontend Testing > Testing conventions, stack, and patterns for granit-front packages. ## Philosophy [Section titled “Philosophy”](#philosophy) Granit-front uses a mixed testing approach: * **Unit tests** for pure functions (`@granit/utils`, `@granit/logger`) * **React integration tests** for hooks and contexts (`@granit/react-authentication`, `@granit/react-authorization`) * **Coverage ≥ 80%** on all new code — blocking requirement ## Test stack [Section titled “Test stack”](#test-stack) | Tool | Role | | -------------------------------------------------------------- | ------------------------------------------------- | | [Vitest](https://vitest.dev/) 3 | Test runner, native ESM compatible | | [@testing-library/react](https://testing-library.com/react) 16 | `render()`, `renderHook()`, `screen`, `waitFor()` | | jsdom 26 | DOM environment for React tests | | v8 (coverage) | Coverage provider — lcov, HTML, Cobertura reports | ## Configuration [Section titled “Configuration”](#configuration) The root `vitest.config.ts` configures the entire workspace: ```typescript export default defineConfig({ test: { globals: true, environment: 'jsdom', include: [ 'packages/@granit/*/src/**/*.test.ts', 'packages/@granit/*/src/**/*.test.tsx', ], coverage: { provider: 'v8', reporter: ['text', 'lcov', 'html', 'cobertura'], reportsDirectory: './coverage', include: ['packages/@granit/*/src/**/*.{ts,tsx}'], exclude: ['**/*.d.ts', '**/*.test.ts', '**/*.test.tsx', '**/*.spec.ts'], }, }, }); ``` ## Commands [Section titled “Commands”](#commands) ```bash pnpm test # Watch mode — development pnpm test:coverage # Single run with coverage pnpm --filter @granit/react-authentication test # Target a specific package ``` ## File structure [Section titled “File structure”](#file-structure) Tests are co-located with sources in `src/__tests__/`: ```text packages/@granit/react-authentication/ └── src/ ├── index.ts ├── keycloak-core.ts ├── use-auth-context.ts ├── mock-provider.tsx └── __tests__/ ├── keycloak-core.test.tsx ├── use-auth-context.test.tsx └── mock-provider.test.tsx ``` ## Naming conventions [Section titled “Naming conventions”](#naming-conventions) | Element | Convention | Example | | ---------------- | ---------------------------- | ---------------------------------------- | | Test file | `.test.ts(x)` | `logger.test.ts` | | `describe` block | Function or hook name | `describe('createLogger', …)` | | `it` block | Expected behavior in English | `it('should log warn in production', …)` | ## Mock patterns [Section titled “Mock patterns”](#mock-patterns) ### Spy on console (`@granit/logger`) [Section titled “Spy on console (@granit/logger)”](#spy-on-console-granitlogger) ```typescript const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); logger.warn('Token expired'); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining('[MyApp]'), 'Token expired', ); ``` ### Mock a module (`@granit/api-client`) [Section titled “Mock a module (@granit/api-client)”](#mock-a-module-granitapi-client) ```typescript vi.mock('axios', () => ({ default: { create: vi.fn(() => ({ interceptors: { request: { use: vi.fn() }, response: { use: vi.fn() }, }, })), }, })); ``` ### Hoisted mocks with mutable state (`@granit/react-authentication`) [Section titled “Hoisted mocks with mutable state (@granit/react-authentication)”](#hoisted-mocks-with-mutable-state-granitreact-authentication) ```typescript const mockKeycloak = vi.hoisted(() => ({ init: vi.fn().mockResolvedValue(true), authenticated: true, token: 'mock-token', loadUserInfo: vi.fn().mockResolvedValue({ sub: '123', name: 'Test' }), updateToken: vi.fn().mockResolvedValue(true), })); vi.mock('keycloak-js', () => ({ default: vi.fn(() => mockKeycloak), })); ``` ### Test a React hook with context [Section titled “Test a React hook with context”](#test-a-react-hook-with-context) ```typescript import { renderHook } from '@testing-library/react'; const wrapper = ({ children }: { children: React.ReactNode }) => ( {children} ); const { result } = renderHook(() => useAuth(), { wrapper }); expect(result.current.authenticated).toBe(true); ``` ## Best practices [Section titled “Best practices”](#best-practices) 1. **One `describe` per exported function/hook** — no flat tests 2. **Self-contained tests** — each `it` is independent, no inter-test dependencies 3. **`mockImplementation(() => {})`** on console spies to suppress noise 4. **`vi.clearAllMocks()`** in `beforeEach` for clean state 5. **Exact assertions** — prefer `toEqual()` over `toMatchObject()` when possible 6. **`act()` and `waitFor()`** for all async tests involving React hooks 7. **No shared test utilities** — each test file is self-sufficient ## Coverage reports [Section titled “Coverage reports”](#coverage-reports) | Format | File | Usage | | --------- | --------------------------------- | -------------------------------- | | Text | terminal | Developer (quick summary) | | HTML | `coverage/index.html` | Developer (detailed exploration) | | LCOV | `coverage/lcov.info` | SonarQube | | Cobertura | `coverage/cobertura-coverage.xml` | CI (PR coverage report) | ## See also [Section titled “See also”](#see-also) * [Frontend Quick Start](/guides/frontend-quick-start/) — integrate granit-front * [Frontend CI/CD](/operations/frontend-ci-cd/) — pipeline and quality gates * [Frontend SDK Reference](/reference/frontend/) — package documentation # Frontend Troubleshooting > Solutions to common issues when developing with granit-front. ## Cannot find module ‘@granit/xxx’ [Section titled “Cannot find module ‘@granit/xxx’”](#cannot-find-module-granitxxx) TypeScript or Vite fails to resolve a `@granit/*` import: ```text Cannot find module '@granit/authentication' or its corresponding type declarations. ``` **Fix:** verify both the `link:` dependency and the Vite alias are configured: * package.json ```json { "dependencies": { "@granit/authentication": "link:../granit-front/packages/@granit/authentication" } } ``` * vite.config.ts ```typescript resolve: { alias: { '@granit/authentication': path.join(GRANIT, 'authentication/src/index.ts'), }, } ``` Then run `pnpm install` in the consuming application. ## TypeScript path resolution [Section titled “TypeScript path resolution”](#typescript-path-resolution) TypeScript reports `TS2307` even though `link:` is configured: ```text TS2307: Cannot find module '@granit/utils' or its corresponding type declarations. ``` **Fix:** add `paths` in **every** tsconfig (`tsconfig.app.json`, `tsconfig.test.json`, `tsconfig.storybook.json`): ```json { "compilerOptions": { "paths": { "@granit/utils": ["../granit-front/packages/@granit/utils/src/index.ts"] } } } ``` Caution TypeScript `paths` and Vite aliases must point to the **same file** (`src/index.ts`). ## Vitest alias not resolved [Section titled “Vitest alias not resolved”](#vitest-alias-not-resolved) Tests fail with a module resolution error for a `@granit/*` package: ```text Error: Failed to resolve import "@granit/querying" from "src/components/..." ``` **Fix:** in `vitest.config.ts`, declare **sub-paths before the main path**: ```typescript resolve: { alias: { // Sub-paths first '@granit/querying/types': path.join(GRANIT, 'querying/src/types/index.ts'), // Main path after '@granit/querying': path.join(GRANIT, 'querying/src/index.ts'), }, } ``` Vitest resolves aliases in declaration order. If the main path is listed first, sub-paths are never reached. ## ESLint import order errors [Section titled “ESLint import order errors”](#eslint-import-order-errors) ESLint reports `import-x/order` after creating a new file: ```text error There should be no empty line within import group import-x/order ``` **Fix:** run the auto-fixer, then verify there are no blank lines between imports of the same group: ```bash npx eslint --fix packages/@granit/my-package/src/my-file.ts ``` Import order: external imports (sorted alphabetically), then internal imports, then `type` imports. ## SignalR mock issues in tests [Section titled “SignalR mock issues in tests”](#signalr-mock-issues-in-tests) Tests in `@granit/notifications` fail with: ```text ReferenceError: HubConnectionBuilder is not defined ``` **Fix:** ensure the mock is hoisted via `vi.hoisted` in the test setup: ```typescript const mockConnection = vi.hoisted(() => ({ start: vi.fn().mockResolvedValue(undefined), stop: vi.fn().mockResolvedValue(undefined), on: vi.fn(), off: vi.fn(), invoke: vi.fn(), state: 'Connected', })); vi.mock('@microsoft/signalr', () => ({ HubConnectionBuilder: vi.fn(() => ({ withUrl: vi.fn().mockReturnThis(), withAutomaticReconnect: vi.fn().mockReturnThis(), build: vi.fn(() => mockConnection), })), HubConnectionState: { Connected: 'Connected', Disconnected: 'Disconnected', }, })); ``` Caution Do not destructure `createConnection` at the top-level of the test file — this creates a stale reference that bypasses the mock. ## Readonly array type mismatch [Section titled “Readonly array type mismatch”](#readonly-array-type-mismatch) TypeScript reports a type error when returning an array: ```text Type 'readonly string[]' is not assignable to type 'string[]'. ``` **Fix:** use `readonly T[]` in return types of hooks and functions: ```typescript // Correct function useItems(): readonly string[] { ... } ``` ## Pre-commit hook failures [Section titled “Pre-commit hook failures”](#pre-commit-hook-failures) The commit is blocked by a Husky hook: ```text husky - pre-commit hook exited with code 1 ``` **Fix:** address the specific failure: 1. **Lint errors** — fix ESLint issues: `pnpm lint` or `npx eslint --fix ...` 2. **TypeScript errors** — fix compilation: `pnpm tsc` 3. **commitlint** — use [Conventional Commits](https://www.conventionalcommits.org/) format: `feat:`, `fix:`, `docs:`, `chore:`, `refactor:`, `test:` Fix the issues, re-stage files (`git add`), and commit again. Never use `--no-verify` to bypass hooks. ## See also [Section titled “See also”](#see-also) * [Frontend Quick Start](/guides/frontend-quick-start/) — initial setup * [Frontend Testing](/guides/frontend-testing/) — test conventions * [Frontend CI/CD](/operations/frontend-ci-cd/) — pipeline configuration # Implement the audit timeline > Add a unified activity feed with system logs, comments, mentions, followers, and notification integration Granit.Timeline provides a unified activity feed (inspired by Odoo Chatter) that combines system-generated audit logs, human comments, and internal notes on any entity. It supports @mentions, threaded replies, file attachments, and follower notifications. ## Prerequisites [Section titled “Prerequisites”](#prerequisites) * A .NET 10 project referencing `Granit.Core` * An EF Core `DbContext` for persistence * `Granit.Notifications` for follower notifications (optional) * `Granit.BlobStorage` for file attachments (optional) ## Step 1 — Install packages [Section titled “Step 1 — Install packages”](#step-1--install-packages) * Core + EF Core ```bash dotnet add package Granit.Timeline dotnet add package Granit.Timeline.EntityFrameworkCore ``` * With notifications ```bash dotnet add package Granit.Timeline dotnet add package Granit.Timeline.EntityFrameworkCore dotnet add package Granit.Timeline.Notifications ``` * With REST endpoints ```bash dotnet add package Granit.Timeline dotnet add package Granit.Timeline.EntityFrameworkCore dotnet add package Granit.Timeline.Endpoints ``` ## Step 2 — Mark entities as timelined [Section titled “Step 2 — Mark entities as timelined”](#step-2--mark-entities-as-timelined) Implement the `ITimelined` marker interface on entities that need an activity feed. The interface has zero members — it is purely opt-in: ```csharp using Granit.Timeline; namespace MyApp.Domain; public sealed class Patient : AuditedEntity, ITimelined { public string Name { get; set; } = string.Empty; public string Email { get; set; } = string.Empty; } ``` To customize the entity type name displayed in the feed: ```csharp [Timelined("Patient Record")] public sealed class Patient : AuditedEntity, ITimelined { public string Name { get; set; } = string.Empty; } ``` ## Step 3 — Register Timeline services [Section titled “Step 3 — Register Timeline services”](#step-3--register-timeline-services) * In-memory (development) ```csharp services.AddGranitTimeline(); ``` * EF Core (production) ```csharp builder.AddGranitTimelineEntityFrameworkCore(opts => opts.UseNpgsql(connectionString)); ``` For notification integration (followers, @mentions): ```csharp services.AddGranitTimelineNotifications(); ``` ## Step 4 — Post timeline entries [Section titled “Step 4 — Post timeline entries”](#step-4--post-timeline-entries) Use `ITimelineStoreWriter` to create entries programmatically. Three entry types are available: | Type | Purpose | Editable | Deletable | | -------------- | ------------------------------------------- | -------- | --------------------------- | | `Comment` | User-facing comment, visible to followers | No | Yes (soft-delete, GDPR) | | `SystemLog` | Auto-generated audit log | No | No (INSERT-only, ISO 27001) | | `InternalNote` | Staff-only note, hidden from external users | No | Yes (soft-delete, GDPR) | Warning `SystemLog` entries are immutable. Attempting to delete one throws `InvalidOperationException`. This enforces ISO 27001 compliance with a minimum 3-year audit retention period. ### Post a system log entry [Section titled “Post a system log entry”](#post-a-system-log-entry) ```csharp using Granit.Timeline; namespace MyApp.Services; public sealed class PatientWorkflowService( ITimelineStoreWriter timelineWriter) { public async Task RecordStatusChangeAsync( Guid patientId, string oldStatus, string newStatus, CancellationToken cancellationToken) { await timelineWriter.PostEntryAsync( entityType: nameof(Patient), entityId: patientId, entryType: TimelineEntryType.SystemLog, body: $"Status changed from {oldStatus} to {newStatus}", cancellationToken: cancellationToken); } } ``` ### Post a comment with @mentions [Section titled “Post a comment with @mentions”](#post-a-comment-with-mentions) The `MentionParser` extracts user GUIDs from Markdown-formatted mentions: ```csharp using Granit.Timeline; namespace MyApp.Services; public sealed class TimelineCommentService( ITimelineStoreWriter timelineWriter, ITimelineFollowerService followerService, ITimelineNotifier notifier) { public async Task PostCommentAsync( string entityType, Guid entityId, string body, CancellationToken cancellationToken) { // 1. Persist the entry var entry = await timelineWriter.PostEntryAsync( entityType, entityId, TimelineEntryType.Comment, body, cancellationToken: cancellationToken); // 2. Extract @mentions from Markdown var mentionedUserIds = MentionParser.ExtractMentionedUserIds(body); // 3. Auto-follow mentioned users foreach (var userId in mentionedUserIds) { await followerService.FollowAsync( entityType, entityId, userId, cancellationToken); } // 4. Notify followers await notifier.NotifyEntryPostedAsync( entry, cancellationToken); // 5. Notify mentioned users specifically await notifier.NotifyMentionedUsersAsync( entry, mentionedUserIds, cancellationToken); } } ``` The mention format is: `@[Dr. Martin](user:550e8400-e29b-41d4-a716-446655440000)` ## Step 5 — Query the timeline [Section titled “Step 5 — Query the timeline”](#step-5--query-the-timeline) Use `ITimelineStoreReader` to retrieve the paginated activity feed for an entity: ```csharp using Granit.Timeline; namespace MyApp.Services; public sealed class PatientTimelineService( ITimelineStoreReader timelineReader) { public async Task GetFeedAsync( Guid patientId, int skip, int take, CancellationToken cancellationToken) => await timelineReader.GetFeedAsync( entityType: nameof(Patient), entityId: patientId, skip: skip, take: take, cancellationToken: cancellationToken); } ``` The feed is sorted in reverse chronological order. Soft-deleted entries are automatically excluded by the EF Core global filter. ## Step 6 — Map REST endpoints [Section titled “Step 6 — Map REST endpoints”](#step-6--map-rest-endpoints) `Granit.Timeline.Endpoints` provides a full Minimal API surface: ```csharp app.MapTimelineEndpoints(); // With a custom route prefix: app.MapTimelineEndpoints(opts => opts.RoutePrefix = "admin/timeline"); ``` ### Available endpoints [Section titled “Available endpoints”](#available-endpoints) | Method | Route | Description | | -------- | --------------------------------------- | --------------------------- | | `GET` | `/{entityType}/{entityId}` | Paginated feed (skip, take) | | `POST` | `/{entityType}/{entityId}/entries` | Post a comment or note | | `DELETE` | `/{entityType}/{entityId}/entries/{id}` | Soft-delete (GDPR) | | `POST` | `/{entityType}/{entityId}/follow` | Subscribe to the entity | | `DELETE` | `/{entityType}/{entityId}/follow` | Unsubscribe | | `GET` | `/{entityType}/{entityId}/followers` | List followers | ### Example POST request [Section titled “Example POST request”](#example-post-request) ```json { "entryType": 0, "body": "Please review @[Dr. Martin](user:550e8400-e29b-41d4-a716-446655440000).", "parentEntryId": null, "attachmentBlobIds": ["a1b2c3d4-e5f6-7890-abcd-ef1234567890"] } ``` ## Step 7 — Manage followers and notifications [Section titled “Step 7 — Manage followers and notifications”](#step-7--manage-followers-and-notifications) The follower system controls who receives notifications when new entries are posted. ```csharp // Subscribe a user to an entity's timeline await followerService.FollowAsync( nameof(Patient), patientId, userId, cancellationToken); // Unsubscribe await followerService.UnfollowAsync( nameof(Patient), patientId, userId, cancellationToken); ``` ### Notification types [Section titled “Notification types”](#notification-types) | Type | Name | Default channels | | --------------------------------- | ------------------------- | --------------------- | | `TimelineCommentNotificationType` | `timeline.comment_posted` | InApp, SignalR | | `TimelineMentionNotificationType` | `timeline.user_mentioned` | InApp, SignalR, Email | The author is automatically excluded from notifications for their own entries. ## Graceful degradation [Section titled “Graceful degradation”](#graceful-degradation) The timeline adapts to available dependencies: | Dependency | Present | Absent | | ---------------------- | ------------------------------------------ | --------------------------------------------------------- | | `Granit.Notifications` | Real follower store, fan-out notifications | `InMemoryTimelineFollowerService`, `NullTimelineNotifier` | | `Granit.BlobStorage` | Pre-signed URLs for attachments | Metadata only | | Multi-tenancy | Automatic `TenantId`, query filters | `TenantId = null` | ## EF Core schema [Section titled “EF Core schema”](#ef-core-schema) The module creates two tables: * `timeline_entries` — primary index on `(EntityType, EntityId, TenantId, CreatedAt DESC)`, optimized for paginated feed queries * `timeline_attachments` — indexed by `(EntryId)` for fast attachment lookup Threaded replies use the `ParentEntryId` column. The API returns a flat list sorted chronologically; the frontend component handles visual threading. ## Next steps [Section titled “Next steps”](#next-steps) * [Encrypt sensitive data](/guides/encrypt-sensitive-data/) — protect sensitive timeline content * [Use reference data](/guides/use-reference-data/) — manage lookup tables with audit trail * [Timeline reference](/reference/modules/timeline/) — full API and configuration details # Implement Data Import > Build a data import pipeline with the 4-step Extract, Map, Validate, Execute flow supporting CSV and Excel files Granit.DataExchange provides a mini-ETL pipeline for importing and exporting tabular data. The import pipeline follows four steps — Extract, Map, Validate, Execute — with streaming `IAsyncEnumerable` processing, intelligent column mapping suggestions, and roundtrip (INSERT vs UPDATE) support. ## Prerequisites [Section titled “Prerequisites”](#prerequisites) * A working Granit application with EF Core configured * A domain entity to import into (e.g., `Patient`) * `Granit.Validation` for row-level validation ## 1. Install the packages [Section titled “1. Install the packages”](#1-install-the-packages) ```bash dotnet add package Granit.DataExchange dotnet add package Granit.DataExchange.EntityFrameworkCore dotnet add package Granit.DataExchange.Endpoints ``` Then add one or both file parsers: * CSV (Sep, SIMD) ```bash dotnet add package Granit.DataExchange.Csv ``` * Excel (Sylvan) ```bash dotnet add package Granit.DataExchange.Excel ``` * Both ```bash dotnet add package Granit.DataExchange.Csv dotnet add package Granit.DataExchange.Excel ``` ## 2. Register services [Section titled “2. Register services”](#2-register-services) ```csharp // Core import pipeline services.AddGranitDataExchange(); // File parsers (at least one required) services.AddGranitDataExchangeCsv(); services.AddGranitDataExchangeExcel(); // EF Core persistence (import jobs, saved mappings) builder.AddGranitDataExchangeEntityFrameworkCore(opts => opts.UseNpgsql(connectionString)); // Import definition and executor for your entity services.AddImportDefinition(); services.AddImportExecutor(); services.AddBusinessKeyResolver(); ``` ## 3. Define an import definition [Section titled “3. Define an import definition”](#3-define-an-import-definition) Each importable entity needs an `ImportDefinition` that declares which properties are importable (whitelist), their display names, aliases, and the business key used for INSERT vs UPDATE resolution: ```csharp public sealed class PatientImportDefinition : ImportDefinition { public override string Name => "Acme.PatientImport"; protected override void Configure(ImportDefinitionBuilder builder) { builder .HasBusinessKey(p => p.Niss) .Property(p => p.Niss, p => p .DisplayName("NISS") .Aliases("National ID", "Numéro national") .Required()) .Property(p => p.FirstName, p => p .DisplayName("First name") .Aliases("Prénom", "Voornaam")) .Property(p => p.LastName, p => p .DisplayName("Last name") .Aliases("Nom", "Achternaam")) .Property(p => p.Email, p => p .DisplayName("Email") .Aliases("Courriel", "Mail", "E-mail")) .Property(p => p.BirthDate, p => p .DisplayName("Date of birth") .Format("dd/MM/yyyy")) .ExcludeOnUpdate(p => p.CreatedAt); } } ``` ### Fluent API reference [Section titled “Fluent API reference”](#fluent-api-reference) | Method | Description | | ----------------------------- | -------------------------------------------- | | `Property(expr, config?)` | Declare an importable property (whitelist) | | `HasBusinessKey(expr)` | Business key for INSERT vs UPDATE resolution | | `HasCompositeKey(exprs)` | Composite key (multiple properties) | | `HasExternalId()` | External ID resolution (Odoo pattern) | | `ExcludeOnUpdate(expr)` | Never overwrite this property on UPDATE | | `GroupBy(column)` | Group rows for parent/child imports | | `HasMany(collection, config)` | Child collection (requires `GroupBy`) | ## 4. The import pipeline [Section titled “4. The import pipeline”](#4-the-import-pipeline) The pipeline processes files through four stages, all streaming via `IAsyncEnumerable`. Only the current batch (default 500 entities) and accumulated errors are held in memory. ```text Upload --> Preview --> Confirm mappings --> Execute (async) ``` ### Stage 1: Extract [Section titled “Stage 1: Extract”](#stage-1-extract) `IFileParser` reads the uploaded file and produces a stream of `RawImportRow` values. Each row contains a dictionary of column names to string values. * CSV ```csharp services.AddGranitDataExchangeCsv(); // Sep (MIT, SIMD AVX-512/NEON) handles RFC 4180 quoting, // configurable separators, and UTF-8 with BOM detection. ``` CSV parsing options: | Option | Default | Description | | ----------- | ------- | ------------------------------------------ | | `Separator` | `","` | Column separator | | `Encoding` | `null` | File encoding (`null` = UTF-8 auto-detect) | | `SkipRows` | `0` | Rows to skip before the header | * Excel ```csharp services.AddGranitDataExchangeExcel(); // Sylvan.Data.Excel (MIT, zero-dep) supports .xlsx, .xls, and .xlsb // via streaming DbDataReader. ``` Supported formats: | MIME type | Format | Extension | | ------------------------------------------------------------------- | -------------------- | --------- | | `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` | Open XML | `.xlsx` | | `application/vnd.ms-excel` | BIFF (Excel 97—2003) | `.xls` | | `application/vnd.ms-excel.sheet.binary.macroenabled.12` | Binary | `.xlsb` | ### Stage 2: Map [Section titled “Stage 2: Map”](#stage-2-map) `IMappingSuggestionService` suggests column-to-property mappings using four strategies in decreasing confidence order: | Level | Source | Confidence | | ----- | ---------------------------------------------------------- | ---------- | | 1 | Saved mappings from previous imports | `Saved` | | 2 | Exact match on property name, DisplayName, or alias | `Exact` | | 3 | Fuzzy match via normalized Levenshtein distance | `Fuzzy` | | 4 | Semantic match via AI (optional `ISemanticMappingService`) | `Semantic` | ### Stage 3: Validate [Section titled “Stage 3: Validate”](#stage-3-validate) `IRowValidator` validates each mapped entity using FluentValidation. Invalid rows are collected in the import report with their error codes and messages. ### Stage 4: Execute [Section titled “Stage 4: Execute”](#stage-4-execute) `IImportExecutor` persists valid entities in batches. For each entity, the `IRecordIdentityResolver` determines whether to INSERT or UPDATE based on the business key. ## 5. Map REST endpoints [Section titled “5. Map REST endpoints”](#5-map-rest-endpoints) ```csharp app.MapDataExchangeEndpoints(); ``` ### Import routes [Section titled “Import routes”](#import-routes) | Method | Route | Description | | ------ | -------------------------- | ---------------------------------------------- | | `POST` | `/data-exchange` | Upload file and create an import job | | `POST` | `/{jobId}/preview` | Get headers, preview rows, mapping suggestions | | `PUT` | `/{jobId}/mappings` | Confirm column mappings | | `POST` | `/{jobId}/execute` | Execute import asynchronously (202 Accepted) | | `POST` | `/{jobId}/dry-run` | Execute synchronous dry run | | `GET` | `/{jobId}` | Job status | | `GET` | `/{jobId}/report` | Import report (statistics + errors) | | `GET` | `/{jobId}/correction-file` | Download file with error rows only | ## 6. Import report [Section titled “6. Import report”](#6-import-report) The import report contains aggregate statistics and only the rows that failed — not the successful rows. For 100,000 rows with 50 errors, only \~50 error objects are held in memory. ```csharp ImportReport report = await executor.ExecuteAsync(entities, options); report.TotalRows; // 100000 report.SucceededRows; // 99950 report.FailedRows; // 50 report.InsertedRows; // 80000 report.UpdatedRows; // 19950 report.Duration; // TimeSpan ``` ## Export pipeline [Section titled “Export pipeline”](#export-pipeline) Granit.DataExchange also provides a full export pipeline with fluent field definitions, saveable presets, and background job support. ### Define an export [Section titled “Define an export”](#define-an-export) ```csharp public sealed class PatientExportDefinition : ExportDefinition { public override string Name => "Acme.PatientExport"; public override string? QueryDefinitionName => "Acme.Patients"; protected override void Configure(ExportDefinitionBuilder builder) { builder .IncludeBusinessKey() .Field(p => p.LastName, f => f.Header("Last name")) .Field(p => p.FirstName, f => f.Header("First name")) .Field(p => p.Email) .Field(p => p.BirthDate, f => f.Header("Date of birth").Format("dd/MM/yyyy")); } } ``` ### Register export services [Section titled “Register export services”](#register-export-services) ```csharp services.AddGranitDataExport(); services.AddExportDefinition(); services.AddScoped, PatientExportDataSource>(); services.AddSingleton(); services.AddSingleton(); ``` ### Export routes [Section titled “Export routes”](#export-routes) | Method | Route | Description | | ------ | ----------------------------------- | --------------------------------- | | `GET` | `/export/definitions` | List export definitions | | `GET` | `/export/definitions/{name}/fields` | Available fields | | `POST` | `/export/jobs` | Create and dispatch an export job | | `GET` | `/export/jobs/{jobId}` | Job status | | `GET` | `/export/jobs/{jobId}/download` | Download exported file | ## Configuration [Section titled “Configuration”](#configuration) ```json { "DataExchange": { "DefaultMaxFileSizeMb": 50, "DefaultBatchSize": 500, "FuzzyMatchThreshold": 0.8 }, "DataExport": { "BackgroundThreshold": 1000 } } ``` ## Next steps [Section titled “Next steps”](#next-steps) * [Add background jobs](/guides/add-background-jobs/) to schedule recurring imports * [Set up notifications](/guides/set-up-notifications/) to alert users when imports complete * [Granit.DataExchange reference](/reference/modules/data-exchange/) for the full API surface and store interfaces # Implement Webhooks > Set up outbound webhooks to notify external systems when business events occur, with HMAC signatures and durable retry Granit.Webhooks delivers outbound HTTP POST notifications to external systems when business events occur. Each webhook is signed with HMAC-SHA256, delivered with at-least-once guarantees via the Wolverine transactional outbox, and recorded in an immutable audit trail. ## Prerequisites [Section titled “Prerequisites”](#prerequisites) * A working Granit application with `Granit.Wolverine` configured * PostgreSQL database (for durable subscription and delivery stores) ## 1. Install the packages [Section titled “1. Install the packages”](#1-install-the-packages) ```bash dotnet add package Granit.Webhooks dotnet add package Granit.Webhooks.EntityFrameworkCore ``` ## 2. Register the module [Section titled “2. Register the module”](#2-register-the-module) * Production (EF Core) ```csharp using Granit.Core.Modularity; using Granit.Webhooks.EntityFrameworkCore; [DependsOn(typeof(GranitWebhooksEntityFrameworkCoreModule))] public sealed class MyAppModule : GranitModule { } ``` * Development (in-memory) ```csharp using Granit.Core.Modularity; using Granit.Webhooks; [DependsOn(typeof(GranitWebhooksModule))] public sealed class MyAppModule : GranitModule { } ``` ## 3. Configure options [Section titled “3. Configure options”](#3-configure-options) ```json { "Webhooks": { "HttpTimeoutSeconds": 10, "MaxParallelDeliveries": 20, "StorePayload": false } } ``` | Option | Default | Description | | ----------------------- | ------- | --------------------------------------------------- | | `HttpTimeoutSeconds` | `10` | Timeout for HTTP requests to subscribers (5—120) | | `MaxParallelDeliveries` | `20` | Parallelism of the `webhook-delivery` queue (1—100) | | `StorePayload` | `false` | Store the full JSON body in delivery attempts | Warning Enabling `StorePayload` persists the full event payload in the audit table. If your payloads contain health data or PII, verify that encryption at rest is enabled and that your DPO has approved this configuration (GDPR/ISO 27001). ## 4. Publish webhook events [Section titled “4. Publish webhook events”](#4-publish-webhook-events) Inject `IWebhookPublisher` and call `PublishAsync` with an event type and payload: ```csharp public sealed class OrderService(IWebhookPublisher webhooks) { public async Task CreateOrderAsync( CreateOrderRequest request, CancellationToken cancellationToken) { // ... business logic ... await webhooks.PublishAsync("order.created", new { orderId = order.Id, amount = order.TotalAmount, }, cancellationToken); } } ``` The publisher serializes the payload, wraps it in a `WebhookTrigger`, and inserts it into the Wolverine outbox. The fan-out handler then resolves all active subscriptions matching the event type and dispatches one `SendWebhookCommand` per subscriber. ## 5. Register webhook endpoints [Section titled “5. Register webhook endpoints”](#5-register-webhook-endpoints) Map the built-in configuration and redelivery endpoints: ```csharp app.MapGranitWebhooksConfig(); // GET /webhooks/config app.MapGranitWebhooksRedelivery(); // POST /webhooks/deliveries/{id}/retry ``` ## Webhook envelope format [Section titled “Webhook envelope format”](#webhook-envelope-format) Each subscriber receives an HTTP POST with this JSON body: ```json { "eventId": "01951234-abcd-7000-8000-000000000001", "eventType": "order.created", "tenantId": "9f3b1234-0000-0000-0000-000000000001", "timestamp": "2025-06-01T14:32:00Z", "apiVersion": "2025-01-01", "data": { "orderId": "...", "amount": 42.50 } } ``` Three custom headers are added to every request: | Header | Value | | --------------------- | --------------------------------- | | `x-granit-signature` | `t=,v1=` | | `x-granit-event-id` | UUID of the event | | `x-granit-event-type` | Event type (e.g. `order.created`) | ## Signature verification (subscriber side) [Section titled “Signature verification (subscriber side)”](#signature-verification-subscriber-side) Subscribers should verify the HMAC-SHA256 signature to authenticate incoming webhooks: ```text toSign = "." secret = shared secret from the subscription (plaintext on receiver side) hmac = HMAC-SHA256(secret, toSign) -- hex lowercase expected = "t=,v1=" ``` The timestamp is included in the signed string to protect against replay attacks. Subscribers should reject requests older than 5 minutes. ## Error handling and retry policy [Section titled “Error handling and retry policy”](#error-handling-and-retry-policy) The delivery handler classifies HTTP response codes into three categories: | Category | Codes | Behavior | | -------------------------- | ------------------ | -------------------------------------- | | Success | 2xx | Records attempt, continues | | Non-retriable (no suspend) | 400, 405, 422 | Records failure, no retry | | Non-retriable (suspend) | 401, 403, 404, 410 | Records failure, suspends subscription | | Retriable | 429, 5xx, timeout | Records failure, retries via outbox | Suspended subscriptions require manual reactivation. This prevents wasting resources on endpoints that have been decommissioned or revoked access. Failed deliveries are retried with exponential backoff: | Attempt | Delay | Elapsed | | ------- | ---------- | ------------- | | 1 | 30 seconds | 30 s | | 2 | 2 minutes | \~2 min 30 s | | 3 | 10 minutes | \~12 min 30 s | | 4 | 30 minutes | \~42 min 30 s | | 5 | 2 hours | \~2 h 43 min | | 6 | 12 hours | \~14 h 43 min | After 6 attempts, the message moves to the Wolverine Dead Letter Queue. ## Redelivery [Section titled “Redelivery”](#redelivery) When `StorePayload = true`, failed deliveries can be replayed: ```http POST /webhooks/deliveries/{deliveryId}/retry ``` | Condition | HTTP code | Reason | | ------------------------ | --------- | ----------------------------------------- | | Attempt not found | 404 | Unknown `deliveryId` | | Attempt succeeded | 400 | No replay on a 2xx delivery | | Subscription deactivated | 409 | Permanently disabled | | Subscription suspended | 202 | Allowed — useful for testing reactivation | ## Secret protection [Section titled “Secret protection”](#secret-protection) In production, webhook signing secrets must be encrypted at rest. Replace the default `NoOpWebhookSecretProtector` with a Vault-backed implementation: ```csharp builder.Services.Replace( ServiceDescriptor.Scoped()); ``` ## Multi-tenancy [Section titled “Multi-tenancy”](#multi-tenancy) Subscriptions support both global and tenant-scoped delivery: * `TenantId = null` — global subscription, receives events from all tenants * `TenantId = ` — tenant-specific, receives only that tenant’s events The fan-out handler automatically resolves the tenant from `ICurrentTenant` or from the `WebhookTrigger.TenantId` property. ## Next steps [Section titled “Next steps”](#next-steps) * [Set up notifications](/guides/set-up-notifications/) for user-facing multi-channel notifications * [Add background jobs](/guides/add-background-jobs/) for recurring scheduled tasks * [Granit.Webhooks reference](/reference/modules/webhooks/) for the full store interfaces and database schema # Implement a workflow > Define finite state machines with guards, approval routing, audit trails, and notification integration using Granit.Workflow Granit.Workflow provides a generic finite state machine (FSM) engine for managing entity lifecycles. It supports permission-based guards, automatic approval routing, ISO 27001 audit trails, and integration with the notification system. ## Prerequisites [Section titled “Prerequisites”](#prerequisites) * A .NET 10 project with Granit module system configured * A PostgreSQL (or other EF Core-supported) database * For approval routing: `Granit.Identity.Keycloak` (or another `IIdentityProvider`) * For notifications: `Granit.Notifications` installed ## Step 1 — Install packages [Section titled “Step 1 — Install packages”](#step-1--install-packages) ```bash # Core FSM engine dotnet add package Granit.Workflow # EF Core interceptor and audit trail dotnet add package Granit.Workflow.EntityFrameworkCore # REST endpoints for transition history (optional) dotnet add package Granit.Workflow.Endpoints # Notification integration for approvals (optional) dotnet add package Granit.Workflow.Notifications ``` ## Step 2 — Define states [Section titled “Step 2 — Define states”](#step-2--define-states) Define an enum representing the possible states of your entity: ```csharp public enum DocumentStatus { Draft, PendingReview, Published, Archived } ``` ## Step 3 — Build a workflow definition [Section titled “Step 3 — Build a workflow definition”](#step-3--build-a-workflow-definition) A `WorkflowDefinition` is an immutable singleton that describes all allowed transitions: ```csharp using Granit.Workflow; WorkflowDefinition definition = WorkflowDefinition.Create(b => b .InitialState(DocumentStatus.Draft) .Transition(DocumentStatus.Draft, DocumentStatus.PendingReview, t => t .Named("Submit for review") .RequiresPermission("document.submit")) .Transition(DocumentStatus.PendingReview, DocumentStatus.Published, t => t .Named("Publish") .RequiresPermission("document.publish") .RequiresApproval()) .Transition(DocumentStatus.Published, DocumentStatus.Archived, t => t .Named("Archive") .RequiresPermission("document.archive")) .Transition(DocumentStatus.Draft, DocumentStatus.Published, t => t .Named("Direct publish") .RequiresPermission("document.publish"))); ``` ### Graph validation [Section titled “Graph validation”](#graph-validation) The builder validates the workflow graph at `Build()` time: * An initial state must be declared * At least one transition must exist * No duplicate transitions (same source and target) * All states must be reachable from the initial state (BFS traversal) Invalid definitions throw at startup, not at runtime. ## Step 4 — Make your entity workflow-aware [Section titled “Step 4 — Make your entity workflow-aware”](#step-4--make-your-entity-workflow-aware) * Workflow only Implement `IWorkflowStateful` on your entity: ```csharp public sealed class Document : Entity, IWorkflowStateful { public string Title { get; set; } = string.Empty; public string Content { get; set; } = string.Empty; public DocumentStatus LifecycleStatus { get; set; } = DocumentStatus.Draft; public bool IsPublished { get; set; } } ``` * Workflow + versioning Inherit from `VersionedWorkflowEntity` for entities that need both FSM and revision history: ```csharp public sealed class Document : VersionedWorkflowEntity { public string Title { get; set; } = string.Empty; public string Content { get; set; } = string.Empty; } ``` `VersionedWorkflowEntity` provides `BusinessId`, `Version`, `LifecycleStatus`, and `IsPublished` out of the box. ## Step 5 — Configure DbContext [Section titled “Step 5 — Configure DbContext”](#step-5--configure-dbcontext) Your application DbContext must implement `IWorkflowDbContext` to store transition records: ```csharp public sealed class AppDbContext : DbContext, IWorkflowDbContext { public DbSet Documents => Set(); public DbSet WorkflowTransitionRecords => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.ConfigureWorkflowModule(); // ... your entity configurations } } ``` ## Step 6 — Register services [Section titled “Step 6 — Register services”](#step-6--register-services) ```csharp // Core workflow engine builder.Services.AddGranitWorkflow(); builder.Services.AddWorkflow(definition); // EF Core interceptor + audit trail builder.Services.AddGranitWorkflowEntityFrameworkCore(); ``` ### Optional: approval routing [Section titled “Optional: approval routing”](#optional-approval-routing) * Identity-based (Keycloak) ```csharp // Resolves approvers from IIdentityProvider + IPermissionManager builder.Services.AddGranitIdentityKeycloak(); builder.Services.AddIdentityApproverResolver(); ``` * Custom resolver ```csharp builder.Services.AddWorkflowApproverResolver(); ``` ### Optional: notifications [Section titled “Optional: notifications”](#optional-notifications) ```csharp builder.Services.AddGranitWorkflowNotifications(); ``` ### Optional: REST endpoints [Section titled “Optional: REST endpoints”](#optional-rest-endpoints) ```csharp builder.Services.AddGranitWorkflowEndpoints(); // After app.Build() app.MapWorkflowEndpoints(); ``` ## Step 7 — Trigger transitions [Section titled “Step 7 — Trigger transitions”](#step-7--trigger-transitions) Use `IWorkflowManager` to transition entities: ```csharp public sealed class DocumentService( AppDbContext dbContext, IWorkflowManager workflowManager) { public async Task SubmitForReviewAsync( Guid documentId, CancellationToken cancellationToken) { var document = await dbContext.Documents.FindAsync( [documentId], cancellationToken); ArgumentNullException.ThrowIfNull(document); await workflowManager.TransitionAsync( document, DocumentStatus.PendingReview, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); } } ``` ## Approval routing [Section titled “Approval routing”](#approval-routing) When a user triggers a transition marked with `RequiresApproval()` but lacks the required permission: 1. The entity moves to `PendingReview` instead of the target state 2. A `WorkflowApprovalRequested` domain event is published 3. If `Granit.Workflow.Notifications` is installed, designated approvers receive a notification 4. An approver (a user with the required permission) completes the transition ### How approvers are resolved [Section titled “How approvers are resolved”](#how-approvers-are-resolved) The built-in `IdentityApproverResolver` follows this flow: 1. **Permission to roles**: queries `IPermissionManager.GetGrantedRolesAsync()` to find roles with the required permission 2. **Roles to users**: for each role, queries `IIdentityProvider.GetRoleMembersAsync()` to get members 3. User IDs are deduplicated and returned ## Audit trail (ISO 27001) [Section titled “Audit trail (ISO 27001)”](#audit-trail-iso-27001) The `WorkflowTransitionInterceptor` automatically creates a `WorkflowTransitionRecord` on every state change detected in `SaveChanges`. These records are INSERT-only — never modified or deleted. Each record captures: * Entity type and identifier * Previous and new state * UTC timestamp * User identifier * Optional comment (regulatory justification) * Tenant context ## IPublishable filter [Section titled “IPublishable filter”](#ipublishable-filter) `IPublishable` is a global EF Core query filter (like `IActive` and `ISoftDeletable`). By default, only entities with `IsPublished = true` are returned by queries. Disable the filter for admin views that need to see all versions: ```csharp using (dataFilter.Disable()) { var allVersions = await dbContext.Documents.ToListAsync(cancellationToken); } ``` ## Publication lifecycle [Section titled “Publication lifecycle”](#publication-lifecycle) Granit provides a pre-built publication workflow via `PublicationWorkflow.Default`: ```text Draft --> PendingReview --> Published --> Archived | ^ +----------> Published -----------------+ (direct publish) ``` Register it instead of building your own: ```csharp builder.Services.AddWorkflow(PublicationWorkflow.Default); ``` ## Adding a transition comment [Section titled “Adding a transition comment”](#adding-a-transition-comment) Use `WorkflowTransitionContext` (AsyncLocal) to attach a regulatory justification to the audit record: ```csharp using (WorkflowTransitionContext.SetComment("Approved per compliance review #42")) { await workflowManager.TransitionAsync( document, DocumentStatus.Published, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); } ``` ## REST endpoint [Section titled “REST endpoint”](#rest-endpoint) The `Granit.Workflow.Endpoints` package exposes a single endpoint: | Method | Route | Description | | ------ | ------------------------------------------- | ------------------------------ | | `GET` | `/workflow/{entityType}/{entityId}/history` | Audit trail of all transitions | ## Next steps [Section titled “Next steps”](#next-steps) * [Create document templates](/guides/create-document-templates/) to use workflow-controlled publication for templates * [Set up end-to-end tracing](/guides/end-to-end-tracing/) to trace workflow transitions across Wolverine handlers * [Workflow reference](/reference/modules/workflow/) for the complete API surface # Manage application settings > Define dynamic settings with cascading resolution across User, Tenant, Global, Configuration, and Default scopes Granit.Settings provides a dynamic settings system with a five-level resolution cascade (`User -> Tenant -> Global -> Configuration -> Default`). Settings can be changed at runtime without redeployment, optionally encrypted at rest, and cached with automatic invalidation. ## Prerequisites [Section titled “Prerequisites”](#prerequisites) * A .NET 10 project referencing `Granit.Core` * `Granit.Settings` for setting definitions and resolution * `Granit.Settings.EntityFrameworkCore` for database persistence (production) * `Granit.Encryption` if you need encrypted settings ## Step 1 — Install packages [Section titled “Step 1 — Install packages”](#step-1--install-packages) ```bash dotnet add package Granit.Settings dotnet add package Granit.Settings.EntityFrameworkCore ``` ## Step 2 — Register the module [Section titled “Step 2 — Register the module”](#step-2--register-the-module) * In-memory (development) ```csharp [DependsOn(typeof(GranitSettingsModule))] public sealed class AppModule : GranitModule { } ``` Uses `InMemorySettingStore` — suitable for tests and local development only. * EF Core (production) ```csharp [DependsOn( typeof(GranitSettingsModule), typeof(GranitSettingsEntityFrameworkCoreModule))] public sealed class AppModule : GranitModule { } ``` ## Step 3 — Define settings [Section titled “Step 3 — Define settings”](#step-3--define-settings) Settings are declared via `ISettingDefinitionProvider`. Create one provider per functional module: ```csharp using Granit.Settings.Definitions; namespace MyApp.Settings; public sealed class AppSettingDefinitionProvider : ISettingDefinitionProvider { public void Define(ISettingDefinitionContext context) { // User-visible setting with a default value context.Add(new SettingDefinition("App.Theme") { DefaultValue = "dark", DisplayName = "Interface theme", IsVisibleToClients = true }); // Encrypted setting (ISO 27001), not exposed to clients context.Add(new SettingDefinition("App.ExternalApiKey") { IsEncrypted = true, IsVisibleToClients = false, Providers = { GlobalSettingValueProvider.ProviderName } }); // Numeric setting restricted to Global and Tenant scopes context.Add(new SettingDefinition("App.MaxUploadMb") { DefaultValue = "10", Providers = { GlobalSettingValueProvider.ProviderName, TenantSettingValueProvider.ProviderName } }); } } ``` Register the provider: ```csharp services.AddSingleton(); ``` ### SettingDefinition properties [Section titled “SettingDefinition properties”](#settingdefinition-properties) | Property | Type | Default | Description | | -------------------- | --------------- | ------- | ---------------------------------------------- | | `Name` | `string` | — | Unique setting key | | `DefaultValue` | `string?` | `null` | Fallback when no provider has a value | | `IsEncrypted` | `bool` | `false` | Encrypt at rest via `IStringEncryptionService` | | `IsVisibleToClients` | `bool` | `false` | Expose via user-facing API endpoints | | `IsInherited` | `bool` | `true` | Cascade to next level when null | | `Providers` | `IList` | `[]` | Allowed providers (empty = all) | ## Step 4 — Read settings [Section titled “Step 4 — Read settings”](#step-4--read-settings) Inject `ISettingProvider` to read settings. The cascade runs automatically: ```csharp using Granit.Settings.Services; namespace MyApp.Services; public sealed class ThemeService(ISettingProvider settings) { public async Task GetThemeAsync( CancellationToken cancellationToken) { var theme = await settings.GetOrNullAsync( "App.Theme", cancellationToken); return theme ?? "dark"; } } ``` The resolution cascade: ```text User (U, order=100) -- per-user preference | null -> Tenant (T, order=200) -- tenant-wide default | null -> Global (G, order=300) -- application-wide | null -> Config (C, order=400) -- appsettings.json / environment variables | null -> Default(D, order=500) -- SettingDefinition.DefaultValue ``` The first non-null value wins. Setting `IsInherited = false` stops the cascade after the first applicable provider, even if it returns null. ## Step 5 — Write settings [Section titled “Step 5 — Write settings”](#step-5--write-settings) Inject `ISettingManager` to create or update setting values at any scope: ```csharp using Granit.Settings.Services; namespace MyApp.Services; public sealed class AdminSettingsService(ISettingManager settings) { public Task SetGlobalThemeAsync( string theme, CancellationToken cancellationToken) => settings.SetGlobalAsync("App.Theme", theme, cancellationToken); public Task SetTenantThemeAsync( Guid tenantId, string theme, CancellationToken cancellationToken) => settings.SetForTenantAsync( tenantId, "App.Theme", theme, cancellationToken); public Task SetUserThemeAsync( string userId, string theme, CancellationToken cancellationToken) => settings.SetForUserAsync( userId, "App.Theme", theme, cancellationToken); } ``` Cache invalidation happens automatically on writes via `ISettingManager`. ## Step 6 — Configure EF Core persistence [Section titled “Step 6 — Configure EF Core persistence”](#step-6--configure-ef-core-persistence) ### Implement ISettingsDbContext [Section titled “Implement ISettingsDbContext”](#implement-isettingsdbcontext) Your application `DbContext` must implement `ISettingsDbContext` and call `ConfigureSettingsModule()`: ```csharp using Granit.Settings.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; namespace MyApp.Persistence; public sealed class AppDbContext( DbContextOptions options) : DbContext(options), ISettingsDbContext { public DbSet SettingRecords { get; set; } = null!; protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); modelBuilder.ConfigureSettingsModule(); modelBuilder.ApplyGranitConventions(currentTenant, dataFilter); } } ``` ### Register the store and run the migration [Section titled “Register the store and run the migration”](#register-the-store-and-run-the-migration) ```csharp // Program.cs -- replaces InMemorySettingStore with EfCoreSettingStore builder.AddGranitSettingsEfCore(); ``` ```bash dotnet ef migrations add InitSettings \ --project src/MyApp \ --startup-project src/MyApp ``` The migration creates the `core_setting_records` table with a unique index on `(Name, ProviderName, ProviderKey)`. ## Step 7 — Override via appsettings.json [Section titled “Step 7 — Override via appsettings.json”](#step-7--override-via-appsettingsjson) The Configuration provider (C, order=400) reads values from `appsettings.json` or environment variables. These have priority over `DefaultValue` but are overridden by Global, Tenant, and User values in the database: ```json { "Settings": { "App.Theme": "light", "App.MaxUploadMb": "50" } } ``` ## Step 8 — Expose settings via REST endpoints (optional) [Section titled “Step 8 — Expose settings via REST endpoints (optional)”](#step-8--expose-settings-via-rest-endpoints-optional) `Granit.Settings.Endpoints` provides Minimal API endpoints for user preferences and admin management: ```csharp // User preferences (authenticated, IsVisibleToClients = true only) app.MapGranitUserSettings(); // Global admin (requires Settings.Global.Read/Manage permissions) app.MapGranitGlobalSettings(); // Tenant admin (requires Settings.Tenant.Read/Manage permissions) app.MapGranitTenantSettings(); ``` | Scope | GET | PUT | DELETE | | ------ | ---------------------- | ----------------------------- | ------------------------------ | | User | `GET /settings/user` | `PUT /settings/user/{name}` | `DELETE /settings/user/{name}` | | Global | `GET /settings/global` | `PUT /settings/global/{name}` | — | | Tenant | `GET /settings/tenant` | `PUT /settings/tenant/{name}` | — | Deleting a user-level setting causes the cascade to fall through to Tenant, then Global, then Configuration, then Default. ## Next steps [Section titled “Next steps”](#next-steps) * [Encrypt sensitive data](/guides/encrypt-sensitive-data/) — encrypt settings with `IsEncrypted = true` * [Add feature flags](/guides/add-feature-flags/) — SaaS feature management with quota guards * [Settings and Features reference](/reference/modules/settings-features/) — full API and configuration details # Set up localization > Configure JSON-based modular localization with 17 cultures, source-generated keys, runtime overrides, and culture fallback Granit.Localization provides a modular JSON-based localization system that integrates with `IStringLocalizer`. Each package embeds its own translations, supports culture fallback via `CultureInfo.Parent`, and allows runtime overrides from the database without redeployment. ## Prerequisites [Section titled “Prerequisites”](#prerequisites) * A .NET 10 project with Granit module system configured * Familiarity with `IStringLocalizer` from `Microsoft.Extensions.Localization` * For runtime overrides: a PostgreSQL (or other EF Core-supported) database ## Step 1 — Install packages [Section titled “Step 1 — Install packages”](#step-1--install-packages) ```bash # Core localization dotnet add package Granit.Localization # Source-generated type-safe keys (optional but recommended) dotnet add package Granit.Localization.SourceGenerator # EF Core override store (optional -- runtime corrections) dotnet add package Granit.Localization.EntityFrameworkCore # HTTP endpoint for SPA clients (optional) dotnet add package Granit.Localization.Endpoints ``` ## Step 2 — Create JSON resource files [Section titled “Step 2 — Create JSON resource files”](#step-2--create-json-resource-files) Each JSON file contains translations for a single culture. Place them under `Localization/{ResourceName}/`: ```text src/MyApp/ Localization/ MyApp/ en.json en-GB.json fr.json fr-CA.json nl.json de.json es.json it.json pt.json pt-BR.json zh.json ja.json pl.json tr.json ko.json sv.json cs.json ``` ### File format [Section titled “File format”](#file-format) ```json { "culture": "en", "texts": { "Patient:NotFound": "Patient not found.", "Validation": { "Required": "This field is required.", "MaxLength": "Maximum {0} characters." } } } ``` ### Regional variants [Section titled “Regional variants”](#regional-variants) Regional files (`fr-CA.json`, `en-GB.json`, `pt-BR.json`) should only contain keys that **differ** from the base language. The .NET native fallback (`CultureInfo.Parent`) resolves automatically: `fr-CA` -> `fr` -> default culture. There is no need for an `en-US.json` file since `en.json` is already US English (native fallback: `en-US` -> `en`). ### Embed as resources [Section titled “Embed as resources”](#embed-as-resources) Mark the files as embedded resources in your `.csproj`: ```xml ``` ## Step 3 — Define a resource marker class [Section titled “Step 3 — Define a resource marker class”](#step-3--define-a-resource-marker-class) Each localization resource is represented by an empty marker class: ```csharp using Granit.Localization.Attributes; [LocalizationResourceName("MyApp")] [InheritResource(typeof(GranitLocalizationResource))] public sealed class MyAppResource; ``` The `[InheritResource]` attribute lets your app inherit base error messages from `GranitLocalizationResource` (`Granit:EntityNotFound`, `Granit:ValidationError`, etc.) without redefining them. ## Step 4 — Register localization [Section titled “Step 4 — Register localization”](#step-4--register-localization) * Module system (recommended) ```csharp [DependsOn(typeof(GranitLocalizationModule))] public sealed class MyAppModule : GranitModule { public override void ConfigureServices(ServiceConfigurationContext context) { context.Services.Configure(options => { options.Resources .Add(defaultCulture: "en") .AddJson( typeof(MyAppModule).Assembly, "MyApp.Localization.MyApp") .AddBaseTypes(typeof(GranitLocalizationResource)); options.DefaultResourceType = typeof(MyAppResource); options.Languages.Add(new LanguageInfo("en", "English", isDefault: true)); options.Languages.Add(new LanguageInfo("fr", "Francais")); options.Languages.Add(new LanguageInfo("nl", "Nederlands")); options.Languages.Add(new LanguageInfo("de", "Deutsch")); }); } } ``` * Direct registration ```csharp builder.Services.AddGranitLocalization(); builder.Services.Configure(options => { options.Resources .Add(defaultCulture: "en") .AddJson(typeof(Program).Assembly, "MyApp.Localization.MyApp") .AddBaseTypes(typeof(GranitLocalizationResource)); options.DefaultResourceType = typeof(MyAppResource); options.Languages.Add(new LanguageInfo("en", "English", isDefault: true)); options.Languages.Add(new LanguageInfo("fr", "Francais")); }); ``` ## Step 5 — Configure request localization [Section titled “Step 5 — Configure request localization”](#step-5--configure-request-localization) Use `Granit.Localization.Endpoints` to auto-configure the ASP.NET Core `RequestLocalizationMiddleware`: ```csharp app.UseGranitRequestLocalization(); ``` This reads the `Languages` list from `GranitLocalizationOptions` and sets `SupportedCultures`, `SupportedUICultures`, and `DefaultRequestCulture` accordingly. ## Step 6 — Use IStringLocalizer [Section titled “Step 6 — Use IStringLocalizer”](#step-6--use-istringlocalizer) Inject `IStringLocalizer` in any service or handler: ```csharp public sealed class PatientService(IStringLocalizer localizer) { public string GetNotFoundMessage(Guid id) => localizer["Patient:NotFound", id]; } ``` ### Culture fallback chain [Section titled “Culture fallback chain”](#culture-fallback-chain) Key resolution follows this order: 1. Current UI culture (`CultureInfo.CurrentUICulture`) — e.g., `fr-BE` 2. Parent culture (`CultureInfo.Parent`) — e.g., `fr` 3. Ancestors up to `CultureInfo.InvariantCulture` 4. Default culture of the resource (`defaultCulture` in `Add()`) 5. Parent resources (inheritance chain) ### Pluralization [Section titled “Pluralization”](#pluralization) SmartFormat.NET provides automatic pluralization based on CLDR rules: ```json { "culture": "en", "texts": { "Files:Count": "{0:No file|One file|{} files}" } } ``` ```csharp var zero = localizer["Files:Count", 0]; // "No file" var one = localizer["Files:Count", 1]; // "One file" var many = localizer["Files:Count", 42]; // "42 files" ``` ## Source-generated keys [Section titled “Source-generated keys”](#source-generated-keys) The `Granit.Localization.SourceGenerator` package eliminates magic strings by generating compile-time constants from your JSON files. ### Configuration [Section titled “Configuration”](#configuration) Declare JSON files as `AdditionalFiles` in the consuming project’s `.csproj`: ```xml ``` ### Generated code [Section titled “Generated code”](#generated-code) From this JSON: ```json { "culture": "en", "texts": { "Patient:NotFound": "Patient not found.", "Patient:Created": "Patient {0} created." } } ``` The source generator produces: ```csharp public static class LocalizationKeys { public static class Patient { public const string NotFound = "Patient:NotFound"; public const string Created = "Patient:Created"; } } ``` Use the constants with full IDE autocompletion: ```csharp // Before -- magic string string message = localizer["Patient:NotFound"]; // After -- type-safe constant string message = localizer[LocalizationKeys.Patient.NotFound]; ``` ## Runtime overrides (database) [Section titled “Runtime overrides (database)”](#runtime-overrides-database) `Granit.Localization.EntityFrameworkCore` enables runtime translation corrections without redeployment. Overrides are stored in PostgreSQL and cached in memory (5-minute TTL by default). ### Set up the override store [Section titled “Set up the override store”](#set-up-the-override-store) ```csharp [DependsOn(typeof(GranitLocalizationEntityFrameworkCoreModule))] public sealed class AppModule : GranitModule { } ``` ```csharp builder.AddGranitLocalizationEntityFrameworkCore(options => options.UseNpgsql(connectionString)); ``` Run the migration: ```bash dotnet ef migrations add AddLocalizationOverrides dotnet ef database update ``` ### Manage overrides programmatically [Section titled “Manage overrides programmatically”](#manage-overrides-programmatically) ```csharp public sealed class TranslationAdminService( ILocalizationOverrideStoreReader storeReader, ILocalizationOverrideStoreWriter storeWriter) { public async Task CorrectTranslationAsync(CancellationToken cancellationToken) { // Override "Patient" with "Beneficiary" for French await storeWriter.SetOverrideAsync( "MyApp", "fr", "Patient.Title", "Beneficiaire", cancellationToken); } public async Task RevertAsync(CancellationToken cancellationToken) { await storeWriter.RemoveOverrideAsync( "MyApp", "fr", "Patient.Title", cancellationToken); } } ``` ### Override CRUD endpoints [Section titled “Override CRUD endpoints”](#override-crud-endpoints) `Granit.Localization.Endpoints` exposes admin endpoints protected by `Localization.Overrides.Manage`: | Method | Route | Description | | -------- | --------------------------------------------------------------- | -------------------------------------- | | `GET` | `/api/granit/localization/overrides` | List overrides by resource and culture | | `PUT` | `/api/granit/localization/overrides/{resource}/{culture}/{key}` | Create or update an override | | `DELETE` | `/api/granit/localization/overrides/{resource}/{culture}/{key}` | Remove an override | ```csharp app.MapGranitLocalizationOverrides(); ``` ### Multi-tenancy [Section titled “Multi-tenancy”](#multi-tenancy) Overrides are isolated by tenant via `ICurrentTenant`. Each tenant sees only its own overrides. Host-level overrides (`TenantId = null`) apply globally. ## Supported cultures [Section titled “Supported cultures”](#supported-cultures) Granit supports 17 cultures out of the box: 14 base languages plus 3 regional variants. | Base languages (14) | Regional variants (3) | | ------------------------------------------------------ | --------------------- | | en, fr, nl, de, es, it, pt, zh, ja, pl, tr, ko, sv, cs | fr-CA, en-GB, pt-BR | Every `src/*/Localization/**/*.json` must exist for all 17 cultures. Regional files only contain keys that differ from the base. ## Next steps [Section titled “Next steps”](#next-steps) * [Create document templates](/guides/create-document-templates/) for culture-specific template rendering * [Configure blob storage](/guides/configure-blob-storage/) — blob storage error messages are resolved via localization JSON files * [Localization reference](/reference/modules/localization/) for the complete API surface # Set Up Notifications > Configure the multi-channel notification engine to deliver messages via Email, SMS, WhatsApp, Push, SignalR, and more Granit.Notifications is a fan-out notification engine that delivers messages across multiple channels from a single `PublishAsync()` call. This guide walks you through installing the engine, registering channels, defining notification types, and publishing notifications to users. ## Prerequisites [Section titled “Prerequisites”](#prerequisites) * A working Granit application with `Granit.Wolverine` configured * PostgreSQL database (for durable stores) * Redis instance (if using SignalR backplane or caching) ## 1. Install the packages [Section titled “1. Install the packages”](#1-install-the-packages) Add the core package and the channels you need: ```bash dotnet add package Granit.Notifications dotnet add package Granit.Notifications.EntityFrameworkCore dotnet add package Granit.Notifications.Endpoints ``` Then add one or more channel packages depending on your requirements: * Email (SMTP) ```bash dotnet add package Granit.Notifications.Email dotnet add package Granit.Notifications.Email.Smtp ``` * Email + SMS + WhatsApp (Brevo) ```bash dotnet add package Granit.Notifications.Email dotnet add package Granit.Notifications.Sms dotnet add package Granit.Notifications.WhatsApp dotnet add package Granit.Notifications.Brevo ``` * SignalR (real-time) ```bash dotnet add package Granit.Notifications.SignalR ``` * Web Push (VAPID) ```bash dotnet add package Granit.Notifications.WebPush ``` * Azure (Email + SMS + Push) ```bash dotnet add package Granit.Notifications.Email dotnet add package Granit.Notifications.Email.AzureCommunicationServices dotnet add package Granit.Notifications.Sms dotnet add package Granit.Notifications.Sms.AzureCommunicationServices dotnet add package Granit.Notifications.MobilePush dotnet add package Granit.Notifications.MobilePush.AzureNotificationHubs ``` ## 2. Register the module [Section titled “2. Register the module”](#2-register-the-module) Declare the module dependency in your application module: ```csharp using Granit.Core.Modularity; using Granit.Notifications.EntityFrameworkCore; [DependsOn(typeof(GranitNotificationsEntityFrameworkCoreModule))] public sealed class MyAppModule : GranitModule { } ``` Register the EF Core store with your database provider: ```csharp builder.AddGranitNotificationsEntityFrameworkCore( opts => opts.UseNpgsql(connectionString)); ``` ## 3. Register channels [Section titled “3. Register channels”](#3-register-channels) Add each channel in `Program.cs`. Only register the channels your application needs — unregistered channels are skipped with a log warning (graceful degradation). ```csharp // Real-time: choose ONE of SSE or SignalR (mutually exclusive) builder.Services.AddGranitNotificationsSignalR("redis:6379"); // OR: builder.Services.AddGranitNotificationsSse(); // Email via MailKit SMTP builder.Services.AddGranitNotificationsEmail(); builder.Services.AddGranitNotificationsEmailSmtp(); // SMS builder.Services.AddGranitNotificationsSms(); // Web Push (W3C VAPID -- no dependency on FCM or APNs) builder.Services.AddGranitNotificationsPush(); ``` ## 4. Map endpoints [Section titled “4. Map endpoints”](#4-map-endpoints) ```csharp app.MapGranitNotificationEndpoints(); ``` This registers the inbox, preferences, subscriptions, and entity follower endpoints under the `/notifications` prefix. ## 5. Configure channels [Section titled “5. Configure channels”](#5-configure-channels) Add the configuration for each registered channel in `appsettings.json`: ```json { "Notifications": { "MaxParallelDeliveries": 8, "Email": { "Provider": "Smtp", "SenderAddress": "noreply@example.com", "SenderName": "My Application" }, "Smtp": { "Host": "smtp.example.com", "Port": 587, "UseSsl": true, "Username": "api-user", "Password": "vault://secret/smtp-password" }, "Push": { "VapidSubject": "mailto:admin@example.com", "VapidPublicKey": "BBase64UrlSafe...", "VapidPrivateKey": "vault://secret/vapid-private-key" }, "SignalR": { "RedisConnectionString": "redis:6379" } } } ``` Warning Never store secrets (API keys, SMTP passwords, VAPID private keys) in plain text in configuration files. Use Vault references (`vault://secret/...`) in production. This is an ISO 27001 requirement. ## 6. Define notification types [Section titled “6. Define notification types”](#6-define-notification-types) Each notification type is declared as a class deriving from `NotificationType`: ```csharp public sealed class OrderShippedNotification : NotificationType { public static readonly OrderShippedNotification Instance = new(); public override string Name => "Orders.Shipped"; public override NotificationSeverity DefaultSeverity => NotificationSeverity.Info; public override IReadOnlyList DefaultChannels => [NotificationChannels.InApp, NotificationChannels.Email, NotificationChannels.Push]; } public sealed record OrderShippedData { public required string OrderId { get; init; } public required string TrackingNumber { get; init; } } ``` Register notification definitions via a provider: ```csharp public sealed class OrderNotificationDefinitionProvider : INotificationDefinitionProvider { public void Define(INotificationDefinitionContext context) { context.Add(new NotificationDefinition("Orders.Shipped") { DisplayName = "Order shipped", Description = "Sent when an order is shipped to the customer.", GroupName = "Orders", DefaultChannels = [NotificationChannels.InApp, NotificationChannels.Email], AllowUserOptOut = true, }); } } // In ConfigureServices: services.AddNotificationDefinitions(); ``` ## 7. Publish notifications [Section titled “7. Publish notifications”](#7-publish-notifications) Inject `INotificationPublisher` and choose one of three publishing strategies: * Explicit recipients ```csharp public sealed class OrderService(INotificationPublisher notifications) { public async Task ShipOrderAsync(Order order, CancellationToken cancellationToken) { // ... business logic ... await notifications.PublishAsync( OrderShippedNotification.Instance, new OrderShippedData { OrderId = order.Id.ToString(), TrackingNumber = order.TrackingNumber, }, recipientUserIds: [order.CustomerId], cancellationToken); } } ``` * Type subscribers ```csharp await notifications.PublishToSubscribersAsync( SystemMaintenanceNotification.Instance, new SystemMaintenanceData { ScheduledAt = maintenanceDate }, cancellationToken); ``` * Entity followers ```csharp await notifications.PublishToEntityFollowersAsync( PatientStatusChangedNotification.Instance, new PatientStatusChangedData { NewStatus = "Active" }, relatedEntity: new EntityReference("Patient", patient.Id.ToString()), cancellationToken); ``` ## 8. Entity tracking (Odoo-style followers) [Section titled “8. Entity tracking (Odoo-style followers)”](#8-entity-tracking-odoo-style-followers) For automatic notifications when tracked properties change, implement `ITrackedEntity` on your EF Core entities: ```csharp public sealed class Patient : Entity, ITrackedEntity { public static string EntityTypeName => "Patient"; public string GetEntityId() => Id.ToString(); public string Status { get; set; } = string.Empty; public static IReadOnlyDictionary TrackedProperties => new Dictionary { ["Status"] = new() { NotificationTypeName = "Patient.StatusChanged", Severity = NotificationSeverity.Warning, }, }; } ``` The `EntityTrackingInterceptor` (provided by `Granit.Notifications.EntityFrameworkCore`) detects changes during `SaveChangesAsync` and automatically publishes notifications to entity followers. Users follow or unfollow entities via the REST API: * `POST /notifications/entity/{entityType}/{entityId}/follow` * `DELETE /notifications/entity/{entityType}/{entityId}/follow` ## Delivery tracking and retry [Section titled “Delivery tracking and retry”](#delivery-tracking-and-retry) Every delivery attempt is recorded in an immutable `NotificationDeliveryAttempt` table (ISO 27001 audit trail). Failed deliveries are retried with exponential backoff: | Attempt | Delay | Elapsed | | ------- | ---------- | ------------- | | 1 | 10 seconds | 10 s | | 2 | 1 minute | \~1 min 10 s | | 3 | 5 minutes | \~6 min 10 s | | 4 | 30 minutes | \~36 min 10 s | | 5 | 2 hours | \~2 h 36 min | After 5 attempts, the message moves to the Wolverine Dead Letter Queue. ## REST API overview [Section titled “REST API overview”](#rest-api-overview) | Method | Route | Description | | -------- | ----------------------------------------- | ----------------------------- | | `GET` | `/notifications` | Paginated inbox | | `GET` | `/notifications/unread/count` | Unread count | | `POST` | `/notifications/{id}/read` | Mark as read | | `POST` | `/notifications/read-all` | Mark all as read | | `GET` | `/notifications/preferences` | User preferences | | `PUT` | `/notifications/preferences` | Update a preference | | `GET` | `/notifications/types` | Registered notification types | | `POST` | `/notifications/subscriptions/{typeName}` | Subscribe to a type | | `DELETE` | `/notifications/subscriptions/{typeName}` | Unsubscribe | ## Next steps [Section titled “Next steps”](#next-steps) * [Implement webhooks](/guides/implement-webhooks/) for outbound system-to-system event delivery * [Add background jobs](/guides/add-background-jobs/) for scheduled tasks * [Granit.Notifications reference](/reference/modules/notifications/) for the full API surface and configuration options # Use reference data > Manage multilingual reference data entities with CRUD endpoints, EF Core persistence, caching, and seeding Granit.ReferenceData provides a generic framework for managing lookup tables (countries, currencies, document types) with built-in i18n support for 14 languages, ISO 27001 audit trail, automatic inactive-entry filtering, and in-memory caching. ## Prerequisites [Section titled “Prerequisites”](#prerequisites) * A .NET 10 project referencing `Granit.Core` * An EF Core `DbContext` for persistence * Familiarity with EF Core entity configurations ## Step 1 — Install packages [Section titled “Step 1 — Install packages”](#step-1--install-packages) ```bash dotnet add package Granit.ReferenceData dotnet add package Granit.ReferenceData.EntityFrameworkCore dotnet add package Granit.ReferenceData.Endpoints ``` ## Step 2 — Define a reference data entity [Section titled “Step 2 — Define a reference data entity”](#step-2--define-a-reference-data-entity) Every reference data type inherits from `ReferenceDataEntity`, which provides: * `Code` — unique business key (e.g., `"BE"`, `"EUR"`) * 14 label properties (`LabelEn`, `LabelFr`, `LabelNl`, `LabelDe`, `LabelEs`, `LabelIt`, `LabelPt`, `LabelZh`, `LabelJa`, `LabelPl`, `LabelTr`, `LabelKo`, `LabelSv`, `LabelCs`) * `Label` — virtual `[NotMapped]` property that resolves automatically based on `CultureInfo.CurrentUICulture`, falling back to `LabelEn` * `IsActive`, `SortOrder`, `ValidFrom`, `ValidTo` * Full audit columns (`CreatedAt`, `CreatedBy`, `ModifiedAt`, `ModifiedBy`) Add domain-specific properties to the subclass: ```csharp using Granit.ReferenceData; namespace MyApp.Domain; public sealed class Country : ReferenceDataEntity { public string Alpha3Code { get; set; } = string.Empty; public string CallingCode { get; set; } = string.Empty; } ``` ## Step 3 — Configure EF Core persistence [Section titled “Step 3 — Configure EF Core persistence”](#step-3--configure-ef-core-persistence) Create a configuration class inheriting from `ReferenceDataEntityTypeConfiguration`. The base class configures the primary key, unique index on `Code`, label columns, audit columns, and the `IsActive` filter. ```csharp using Granit.ReferenceData.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; using MyApp.Domain; namespace MyApp.Persistence; public sealed class CountryConfiguration : ReferenceDataEntityTypeConfiguration { public CountryConfiguration() : base("ref_countries") { } protected override void ConfigureEntity(EntityTypeBuilder builder) { builder.Property(e => e.Alpha3Code) .HasMaxLength(3) .IsRequired(); builder.Property(e => e.CallingCode) .HasMaxLength(10); } } ``` Apply the configuration in your `DbContext`: ```csharp protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); modelBuilder.ConfigureReferenceData(new CountryConfiguration()); modelBuilder.ConfigureReferenceData(new CurrencyConfiguration()); modelBuilder.ApplyGranitConventions(currentTenant, dataFilter); } ``` ## Step 4 — Register the store [Section titled “Step 4 — Register the store”](#step-4--register-the-store) Register the EF Core store for each reference data type. This replaces the in-memory default and adds cache-aside behavior: ```csharp services.AddReferenceDataStore(); services.AddReferenceDataStore(); ``` This registers: * `IReferenceDataStoreReader` / `IReferenceDataStoreWriter` (Scoped) — EF Core store with memory cache * `IDataSeedContributor` — bridge for typed seeders ## Step 5 — Seed initial data [Section titled “Step 5 — Seed initial data”](#step-5--seed-initial-data) Implement `IReferenceDataSeeder` for idempotent data seeding. Seeders are ordered by the `Order` property and executed via the `IDataSeedContributor` pipeline: ```csharp using Granit.ReferenceData; using MyApp.Domain; namespace MyApp.Persistence; public sealed class CountrySeeder : IReferenceDataSeeder { public int Order => 1; public async Task SeedAsync( IReferenceDataStoreReader storeReader, IReferenceDataStoreWriter storeWriter, CancellationToken cancellationToken) { var existing = await storeReader.GetByCodeAsync( "BE", cancellationToken); if (existing is null) { await storeWriter.CreateAsync(new Country { Code = "BE", LabelEn = "Belgium", LabelFr = "Belgique", LabelNl = "Belgi\u00eb", LabelDe = "Belgien", Alpha3Code = "BEL", CallingCode = "+32" }, cancellationToken); } } } ``` Register the seeder: ```csharp services.AddTransient, CountrySeeder>(); ``` ## Step 6 — Map CRUD endpoints [Section titled “Step 6 — Map CRUD endpoints”](#step-6--map-crud-endpoints) `Granit.ReferenceData.Endpoints` exposes Minimal API endpoints for reading and administering reference data: ```csharp app.MapReferenceDataEndpoints(); app.MapReferenceDataEndpoints(opts => { opts.RoutePrefix = "api/ref"; opts.AdminPolicyName = "Custom.Admin"; }); ``` The entity name is converted to kebab-case for the route segment: `Country` becomes `/reference-data/country`. ### Available endpoints [Section titled “Available endpoints”](#available-endpoints) **Read (public):** | Method | Route | Description | | ------ | --------------------------- | --------------------------- | | `GET` | `/{prefix}/{entity}` | Filtered and paginated list | | `GET` | `/{prefix}/{entity}/{code}` | Single entry by code | Query parameters: `activeOnly` (default `true`), `search`, `sortBy`, `descending`, `skip`, `take`. **Admin (protected by `ReferenceData.Admin` policy):** | Method | Route | Description | | -------- | --------------------------- | ------------------- | | `POST` | `/{prefix}/{entity}` | Create an entry | | `PUT` | `/{prefix}/{entity}/{code}` | Update an entry | | `DELETE` | `/{prefix}/{entity}/{code}` | Deactivate an entry | ## Step 7 — Query reference data in code [Section titled “Step 7 — Query reference data in code”](#step-7--query-reference-data-in-code) Use `IReferenceDataStoreReader` to query reference data with filtering and pagination: ```csharp using Granit.ReferenceData; using MyApp.Domain; namespace MyApp.Services; public sealed class CountryService( IReferenceDataStoreReader storeReader) { public async Task> SearchAsync( string? searchTerm, CancellationToken cancellationToken) => await storeReader.GetAllAsync( new ReferenceDataQuery( ActiveOnly: true, SearchTerm: searchTerm, SortBy: "Code", Skip: 0, Take: 25), cancellationToken); public async Task GetByCodeAsync( string code, CancellationToken cancellationToken) => await storeReader.GetByCodeAsync(code, cancellationToken); } ``` ## Next steps [Section titled “Next steps”](#next-steps) * [Manage application settings](/guides/manage-application-settings/) — dynamic settings with cascading resolution * [Implement the audit timeline](/guides/implement-audit-timeline/) — track changes to reference data * [Create a module](/guides/create-a-module/) — package reference data types into a reusable module # Use Granit docs with AI assistants > Get accurate, context-aware answers about Granit from ChatGPT, Claude, Copilot, or any LLM. Stop copy-pasting code snippets into your AI assistant. Granit exposes its entire documentation as plain-text files that any LLM can ingest in one shot — so it answers with real framework knowledge, not guesswork. ## ChatGPT [Section titled “ChatGPT”](#chatgpt) 1. Open [chatgpt.com](https://chatgpt.com) and start a new conversation. 2. Click the attachment icon and upload [`llms-full.txt`](https://granit-fx.dev/llms-full.txt). 3. Ask your question — ChatGPT now has the full Granit documentation as context. **For repeated use**, create a Custom GPT and add [`https://granit-fx.dev/llms-full.txt`](https://granit-fx.dev/llms-full.txt) as a knowledge file. Every conversation will start with Granit context built in. ## Claude [Section titled “Claude”](#claude) ### claude.ai [Section titled “claude.ai”](#claudeai) 1. Open [claude.ai](https://claude.ai) and start a new conversation. 2. Attach [`llms-full.txt`](https://granit-fx.dev/llms-full.txt) as a file. 3. Ask your question. **For repeated use**, create a Claude Project and add the file as project knowledge — it stays available across all conversations in that project. ### Claude Code [Section titled “Claude Code”](#claude-code) Add this to your project’s `CLAUDE.md` so Claude Code loads Granit context automatically: ```markdown ## Granit documentation Full framework reference: https://granit-fx.dev/llms-full.txt ``` Every conversation in the project will have access to the full module reference, patterns, and ADRs. ## GitHub Copilot [Section titled “GitHub Copilot”](#github-copilot) Add a `.github/copilot-instructions.md` file to your repository so Copilot always knows about Granit: ```markdown When answering questions about the Granit framework, refer to the full documentation at: https://granit-fx.dev/llms-full.txt ``` Then ask Copilot Chat as usual: ```text @workspace How does Granit handle multi-tenancy? ``` ## Other tools [Section titled “Other tools”](#other-tools) Any LLM tool that supports the [llms.txt](https://llmstxt.org/) standard will discover the documentation automatically from [`https://granit-fx.dev/llms.txt`](https://granit-fx.dev/llms.txt). For tools with smaller context windows, use the compact version: [`https://granit-fx.dev/llms-small.txt`](https://granit-fx.dev/llms-small.txt). # Migration > Versioning strategy, breaking change policy, and upgrade guides for Granit Granit follows [Semantic Versioning 2.0.0](https://semver.org/). All 135 packages share a single version number and are released together from the monorepo. This section documents breaking changes and provides upgrade guides between major versions. ## Versioning strategy [Section titled “Versioning strategy”](#versioning-strategy) ### Single version for all packages [Section titled “Single version for all packages”](#single-version-for-all-packages) Every Granit NuGet package (`Granit.Core`, `Granit.Persistence`, `Granit.Notifications.Email.Smtp`, etc.) ships under the same version number. When you upgrade one package, upgrade them all. This eliminates version matrix issues and guarantees that cross-package contracts remain consistent. ```xml ``` ### SemVer rules [Section titled “SemVer rules”](#semver-rules) | Version bump | Meaning | Example | | ------------------- | ------------------------------------------------------------------------------- | ---------------------------------------- | | **Major** (`X.0.0`) | Breaking changes — public API removals, behavioral changes, required migrations | Removing a method from `IGuidGenerator` | | **Minor** (`0.X.0`) | New features, backward-compatible additions | Adding `Granit.Notifications.WhatsApp` | | **Patch** (`0.0.X`) | Bug fixes, performance improvements, documentation corrections | Fixing a race condition in `HybridCache` | ### Pre-release versions [Section titled “Pre-release versions”](#pre-release-versions) Pre-release packages use the `-preview.N` suffix (e.g., `2.0.0-preview.1`). These are published to GitHub Packages for early testing. Public API may change between preview releases without notice. ## Breaking change policy [Section titled “Breaking change policy”](#breaking-change-policy) Breaking changes are introduced only in major version bumps. Each major release includes: * A detailed changelog entry listing every breaking change * Migration instructions with before/after code samples * The rationale for the change * A minimum deprecation period of one minor release cycle before removal ### Deprecation workflow [Section titled “Deprecation workflow”](#deprecation-workflow) 1. The API is marked with `[Obsolete("Use XYZ instead. Will be removed in vN.0.0")]` 2. The deprecation appears in the changelog for the minor release 3. The API is removed in the next major release ### What counts as a breaking change [Section titled “What counts as a breaking change”](#what-counts-as-a-breaking-change) * Removing or renaming a public type, method, or property * Changing method signatures (parameter types, return types) * Changing default behavior that existing consumers rely on * Removing a `[DependsOn]` dependency that downstream modules expect * Changing database schema in a way that requires manual migration * Removing or renaming configuration keys ### What does not count as a breaking change [Section titled “What does not count as a breaking change”](#what-does-not-count-as-a-breaking-change) * Adding new optional parameters with default values * Adding new public types or methods * Adding new `[DependsOn]` dependencies * Performance improvements * Bug fixes (even if code relied on the buggy behavior) ## Upgrade checklist [Section titled “Upgrade checklist”](#upgrade-checklist) When upgrading Granit to a new version: 1. **Read the changelog** for the target version and every version in between 2. **Update all Granit packages** to the same version simultaneously 3. **Run `dotnet build`** and fix any compilation errors 4. **Run `dotnet test`** to catch behavioral regressions 5. **Check EF Core migrations** — if schema changes are involved, generate and review a new migration 6. **Review deprecated API warnings** and plan replacements before the next major release ## Section contents [Section titled “Section contents”](#section-contents) * [Changelog](./changelog/) — format, conventions, and version history # Changelog > Changelog format, conventions, and how to read Granit version history Granit maintains a `CHANGELOG.md` file in the repository root following the [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format combined with [Conventional Commits](https://www.conventionalcommits.org/). ## Where to find it [Section titled “Where to find it”](#where-to-find-it) The canonical changelog lives at the repository root: ```text granit-dotnet/ CHANGELOG.md <-- version history src/ tests/ ... ``` ## Format conventions [Section titled “Format conventions”](#format-conventions) Each release section uses the following structure: ```markdown ## [1.2.0] - 2026-03-10 ### Added - feat(notifications): add WhatsApp delivery channel (`Granit.Notifications.WhatsApp`) - feat(templating): add Scriban `date.to_iso` custom function ### Changed - refactor(persistence): improve `AuditedEntityInterceptor` batching performance ### Deprecated - deprecate(caching): `IDistributedCacheManager.Remove()` -- use `RemoveAsync()` instead ### Fixed - fix(identity): correct Keycloak token refresh race condition - fix(blob-storage): handle S3 multipart upload timeout on large files ### Security - security(vault): rotate transit encryption keys on startup when key age exceeds threshold ### Breaking Changes - breaking(core): rename `IGranitModule.Initialize()` to `OnApplicationInitialization()` - **Migration**: rename the method override in your module classes ``` ### Categories [Section titled “Categories”](#categories) | Category | When to use | | -------------------- | ---------------------------------------------------------- | | **Added** | New features, new packages, new public APIs | | **Changed** | Non-breaking changes to existing functionality | | **Deprecated** | APIs marked with `[Obsolete]`, scheduled for removal | | **Removed** | APIs removed (major versions only) | | **Fixed** | Bug fixes | | **Security** | Vulnerability patches, security improvements | | **Breaking Changes** | Changes that require consumer action (major versions only) | ### Scope conventions [Section titled “Scope conventions”](#scope-conventions) The scope in parentheses maps to the Granit module area: | Scope | Packages | | --------------- | ------------------------------------------------------------------------------------ | | `core` | `Granit.Core`, `Granit.Timing`, `Granit.Guids` | | `persistence` | `Granit.Persistence`, `Granit.Persistence.Migrations` | | `caching` | `Granit.Caching`, `Granit.Caching.Hybrid`, `Granit.Caching.StackExchangeRedis` | | `identity` | `Granit.Identity`, `Granit.Identity.Keycloak`, `Granit.Identity.EntityFrameworkCore` | | `notifications` | All `Granit.Notifications.*` packages | | `templating` | `Granit.Templating`, `Granit.Templating.Scriban`, `Granit.DocumentGeneration` | | `blob-storage` | `Granit.BlobStorage`, `Granit.BlobStorage.S3` | | `security` | `Granit.Security`, `Granit.Authentication.*`, `Granit.Authorization.*` | | `vault` | `Granit.Vault`, `Granit.Encryption` | | `observability` | `Granit.Observability`, `Granit.Diagnostics` | | `workflow` | All `Granit.Workflow.*` packages | | `data-exchange` | All `Granit.DataExchange.*` packages | | `localization` | All `Granit.Localization.*` packages | ## Reading version numbers [Section titled “Reading version numbers”](#reading-version-numbers) Given version `1.2.3`: * **1** (major) — incrementing this means breaking changes exist. Read the “Breaking Changes” section carefully. * **2** (minor) — new features were added. Your existing code continues to work without changes. * **3** (patch) — bug fixes only. Safe to upgrade without reviewing the changelog in detail. ## Automation [Section titled “Automation”](#automation) Changelog entries are derived from Conventional Commit messages. The commit types map directly to changelog categories: | Commit prefix | Changelog category | | ------------------------ | ----------------------------------------- | | `feat:` | Added | | `refactor:`, `perf:` | Changed | | `fix:` | Fixed | | `security:` | Security | | `docs:` | Not included (documentation-only changes) | | `test:`, `ci:`, `chore:` | Not included | Commits with a `BREAKING CHANGE:` footer or a `!` after the type (e.g., `feat!:`) are additionally listed under **Breaking Changes**. # Operations > Deployment, observability, and production configuration for Granit applications This section covers the operational aspects of deploying and running Granit applications on European sovereign infrastructure. ## Audience [Section titled “Audience”](#audience) * **SRE**: observability configuration, alerting, incident response * **DevOps engineers**: CI/CD pipelines, Kubernetes, Helm * **Platform engineers**: infrastructure sizing, compliance, capacity planning ## Guides [Section titled “Guides”](#guides) | Guide | Description | | ----------------------------------------------- | ---------------------------------------------------------- | | [Deployment](./deployment/) | Kubernetes deployment, Docker, health probes, scaling | | [Configuration](./configuration/) | Vault secrets, environment variables, appsettings layering | | [Observability](./observability/) | LGTM stack, Serilog, OpenTelemetry, Grafana dashboards | | [CI/CD](./ci-cd/) | GitHub Actions pipeline, build, test, pack, publish | | [Production checklist](./production-checklist/) | Go-live verification for security, GDPR, ISO 27001 | ## Sovereign infrastructure [Section titled “Sovereign infrastructure”](#sovereign-infrastructure) All Granit applications handling sensitive data **must** be hosted on European infrastructure compliant with ISO 27001: | Component | Technology | Constraint | | -------------- | ------------------------------------------- | ---------------------------- | | Compute | Managed Kubernetes (EU region) | Data residency in EU | | Database | PostgreSQL (managed or self-hosted) | Encrypted at rest | | Cache | Redis (managed or self-hosted) | Password-protected via Vault | | Secrets | HashiCorp Vault (self-hosted, Raft storage) | No SaaS secret managers | | Observability | LGTM stack (Loki, Grafana, Tempo, Mimir) | Self-hosted, EU only | | Object storage | S3-compatible (MinIO or EU provider) | Encrypted, tenant-isolated | Data sovereignty Telemetry, logs, and traces contain personally identifiable information (tenant IDs, user IDs, correlation data). All observability data must remain within European infrastructure. US-based cloud services are not permitted for data subject to GDPR and ISO 27001 compliance. ## Packages referenced in this section [Section titled “Packages referenced in this section”](#packages-referenced-in-this-section) | Package | Role | | ----------------------------- | --------------------------------------------------------------------- | | `Granit.Diagnostics` | Kubernetes health check endpoints (liveness, readiness, startup) | | `Granit.Observability` | Serilog + OpenTelemetry OTLP export to LGTM stack | | `Granit.Vault` | HashiCorp Vault integration (dynamic credentials, Transit encryption) | | `Granit.Cors` | CORS policy configuration | | `Granit.ExceptionHandling` | RFC 7807 Problem Details error responses | | `Granit.Wolverine.Postgresql` | Wolverine messaging with PostgreSQL transport | # CI/CD > GitHub Actions CI/CD pipeline for building, testing, and publishing Granit packages This guide covers the CI/CD pipeline for the Granit framework: compilation, quality gates, security scanning, static analysis, NuGet packaging, and publication to GitHub Packages and nuget.org. ## Pipeline overview [Section titled “Pipeline overview”](#pipeline-overview) The pipeline runs on every pull request, push to `develop`/`main`, and release tag (`vX.Y.Z`). Concurrent runs on the same ref are cancelled automatically. | Phase | Jobs | Blocking | | ----------- | --------------------------------------------------------- | ------------------------------------------------------------------ | | 1. build | `build` | Yes | | 2. quality | `format`, `test`, `architecture-test`, `integration-test` | `format`, `test`, `architecture-test`: yes. `integration-test`: no | | 3. security | `secret-detection`, `trivy`, `codeql` | `secret-detection` and `trivy`: yes | | 4. analysis | `sonarcloud`, `audit` | No (advisory) | | 5. pack | `pack` | Yes (develop/main/tags only) | | 6. publish | `publish-github`, `publish-nuget` | Yes | | 7. docs | `docs` | No (main only) | ## Build commands [Section titled “Build commands”](#build-commands) These are the core commands used in CI and available for local development: ```bash # Compile the solution dotnet build # Run all unit tests with coverage dotnet test --collect:"XPlat Code Coverage" # Verify code formatting (fails if changes needed) dotnet format --verify-no-changes # Create NuGet packages dotnet pack -c Release -o ./nupkgs ``` ## Job details [Section titled “Job details”](#job-details) ### build [Section titled “build”](#build) Compiles the full solution on `ubuntu-latest` with .NET 10. Integration tests are excluded from compilation at this stage (`-p:SkipIntegrationTests=true`) to avoid requiring Docker. Build artifacts (`bin/` and `obj/`) are uploaded for downstream jobs. ### format [Section titled “format”](#format) Runs `dotnet format --verify-no-changes` against the build output. This is a hard gate: pull requests with formatting violations are blocked. ### test [Section titled “test”](#test) Runs unit tests with OpenCover and Cobertura coverage output. Architecture tests and integration tests are excluded (`-p:SkipArchitectureTests=true -p:SkipIntegrationTests=true`). Coverage reports are uploaded as artifacts for SonarCloud consumption. The job has a 15-minute timeout. ### architecture-test [Section titled “architecture-test”](#architecture-test) Runs the `Granit.ArchitectureTests` project separately to validate cross-cutting architecture rules (NetArchTest). The job has a 10-minute timeout. ### integration-test [Section titled “integration-test”](#integration-test) Uses a PostgreSQL 18 service container to validate tenant isolation (ISO 27001 requirement). Each `*.Tests.Integration` project is run sequentially. Marked `continue-on-error` because it depends on service container availability. ### security [Section titled “security”](#security) Three security jobs run in parallel, independently of the build: | Job | Tool | Purpose | | ------------------ | ------------------------------------------------ | --------------------------------------------------- | | `secret-detection` | [Gitleaks](https://github.com/gitleaks/gitleaks) | Detects committed secrets (API keys, passwords) | | `trivy` | [Trivy](https://github.com/aquasecurity/trivy) | Filesystem vulnerability scan (HIGH/CRITICAL) | | `codeql` | [CodeQL](https://codeql.github.com/) | Semantic code analysis for security vulnerabilities | Gitleaks and Trivy are blocking. CodeQL results appear in the repository **Security** tab under code scanning alerts. ### sonarcloud [Section titled “sonarcloud”](#sonarcloud) Static analysis with code coverage integration. Receives OpenCover reports from the `test` job. Requires `SONAR_TOKEN`. Runs only when the pull request originates from the same repository (not from forks). Marked `continue-on-error` so pipeline completion is not blocked by SonarCloud availability. ### audit [Section titled “audit”](#audit) Runs `dotnet list package --vulnerable --include-transitive` and fails if any vulnerable package is found. The vulnerability report is saved as a job artifact. Marked `continue-on-error` (advisory). ### pack [Section titled “pack”](#pack) Creates NuGet packages with automatic versioning. Runs only on `develop`, `main`, or version tags: | Context | Version format | | ----------------------- | ------------------------------------- | | Release tag `vX.Y.Z` | `X.Y.Z` (stable release) | | Branch `develop`/`main` | `0.1.0-dev.` (prerelease) | Packed `.nupkg` files are uploaded as the `nupkgs` artifact. ### publish-github [Section titled “publish-github”](#publish-github) Pushes `.nupkg` files to **GitHub Packages** on pushes to `develop` or `main`. Authentication uses the automatic `GITHUB_TOKEN` with `packages: write` permission. No additional secrets are required. ### publish-nuget [Section titled “publish-nuget”](#publish-nuget) Pushes `.nupkg` files to **nuget.org** on version tags (`vX.Y.Z`). Requires the `NUGET_API_KEY` secret and uses the `nuget-publish` environment for deployment protection rules. ### docs [Section titled “docs”](#docs) Builds the Astro Starlight documentation site and deploys it to **GitHub Pages** on pushes to `main`. Uses pnpm with Node.js 22. The deployed URL is available in the `github-pages` environment. ## NuGet cache strategy [Section titled “NuGet cache strategy”](#nuget-cache-strategy) Each job uses the `actions/cache` action to share the NuGet package cache, keyed by `runner.os` and a hash of `**/*.csproj` + `Directory.Packages.props`. Restore completes in 5-10 seconds with a warm cache. Build artifacts are shared via `actions/upload-artifact` / `actions/download-artifact` to avoid redundant compilation in downstream jobs. ## CI variables [Section titled “CI variables”](#ci-variables) ### Required secrets [Section titled “Required secrets”](#required-secrets) | Secret | Description | Used by | | --------------- | ----------------- | --------------------------------- | | `NUGET_API_KEY` | nuget.org API key | `publish-nuget` (tag builds only) | ### Optional secrets [Section titled “Optional secrets”](#optional-secrets) | Secret | Description | Used by | | ------------- | ------------------------------- | ------------ | | `SONAR_TOKEN` | SonarCloud authentication token | `sonarcloud` | ### Automatic tokens [Section titled “Automatic tokens”](#automatic-tokens) `GITHUB_TOKEN` is provided automatically by GitHub Actions and is used for Gitleaks scanning, GitHub Packages publishing, and GitHub Pages deployment. No manual configuration is required. ## Consuming Granit packages [Section titled “Consuming Granit packages”](#consuming-granit-packages) Applications that depend on Granit add GitHub Packages as a NuGet source: ```xml ``` Authentication uses `GITHUB_TOKEN` in CI environments. For local development, add credentials in `packageSourceCredentials`: ```xml ``` The personal access token needs the `read:packages` scope. ## Definition of Done [Section titled “Definition of Done”](#definition-of-done) Before any pull request is approved, the following gates must pass: * [ ] `dotnet build` succeeds * [ ] `dotnet test` passes with adequate coverage * [ ] `dotnet format --verify-no-changes` passes * [ ] Architecture tests pass * [ ] No HIGH/CRITICAL vulnerabilities (Trivy) * [ ] Secret detection scan clean (Gitleaks) * [ ] Documentation updated (if applicable) Blocking gates Tests passing, format clean, and documentation updated are **blocking** requirements. Do not merge without satisfying all gates. See the full [Definition of Done](/contributing/definition-of-done/) for details. ## Troubleshooting [Section titled “Troubleshooting”](#troubleshooting) ### Test job times out [Section titled “Test job times out”](#test-job-times-out) The `test` job has a 15-minute timeout. If tests are slow, check for non-parallelized test collections. You can adjust the max CPU count in the workflow: ```yaml -- RunConfiguration.MaxCpuCount=4 ``` ### SonarCloud shows 0% coverage [Section titled “SonarCloud shows 0% coverage”](#sonarcloud-shows-0-coverage) Verify that: 1. The `test` job produces `**/coverage.opencover.xml` artifacts. 2. The `sonarcloud` job downloads the `coverage` artifact. 3. `sonar.cs.opencover.reportsPaths` points to `**/coverage.opencover.xml`. ### audit job fails [Section titled “audit job fails”](#audit-job-fails) A NuGet dependency has a known vulnerability. Check the `vulnerability-report` artifact for details. Update the affected package or, if a fix is not available, document the risk assessment and mark the advisory as accepted. ### Integration tests fail with connection errors [Section titled “Integration tests fail with connection errors”](#integration-tests-fail-with-connection-errors) Verify that: 1. The PostgreSQL service container is healthy (check the job logs for health check output). 2. Environment variables `POSTGRES_HOST`, `POSTGRES_PORT`, `POSTGRES_DB`, `POSTGRES_USER`, and `POSTGRES_PASSWORD` match the service definition. 3. The test fixture connection string uses `localhost:5432` (service containers are mapped to the runner’s localhost). ### CodeQL analysis is slow [Section titled “CodeQL analysis is slow”](#codeql-analysis-is-slow) CodeQL performs a full build for semantic analysis. If the job exceeds the default timeout, ensure `SkipIntegrationTests=true` is set to reduce build scope. CodeQL results appear in the repository **Security > Code scanning alerts** tab. ### publish-github fails with 403 [Section titled “publish-github fails with 403”](#publish-github-fails-with-403) Ensure the workflow has `packages: write` permission. This is declared in the `publish-github` job definition. For organization repositories, verify that GitHub Actions has permission to create packages in the organization settings. # Configuration > Production configuration with Vault, environment variables, and appsettings layering This guide covers production configuration for Granit applications: secret management with HashiCorp Vault, environment variable overrides, appsettings layering, and dynamic database credentials. ## Configuration layering [Section titled “Configuration layering”](#configuration-layering) Granit follows the standard ASP.NET Core configuration precedence (last wins): 1. `appsettings.json` — base defaults, committed to source control 2. `appsettings.{Environment}.json` — environment-specific overrides 3. Environment variables — container-level overrides (Kubernetes `env` or `ConfigMap`) 4. HashiCorp Vault — secrets (dynamic credentials, encryption keys, API tokens) No secrets in appsettings `appsettings.json` and `appsettings.Production.json` must never contain secrets. Connection strings, API keys, and passwords come exclusively from Vault or environment variables injected from Vault. This is enforced by CI secret detection scans and ISO 27001 audit requirements. ## Vault integration (Granit.Vault) [Section titled “Vault integration (Granit.Vault)”](#vault-integration-granitvault) ### Architecture [Section titled “Architecture”](#architecture) In production, Granit authenticates to Vault using the pod’s Kubernetes ServiceAccount. No static tokens or passwords are stored anywhere. The `Granit.Vault` package (built on VaultSharp 1.17+) provides: * **Kubernetes authentication**: automatic JWT-based login using the ServiceAccount token * **Dynamic PostgreSQL credentials**: ephemeral database users with short TTLs * **Transit encryption**: field-level encryption without exposing keys to the application * **Automatic lease renewal**: the `IVaultCredentialLeaseManager` renews leases before expiration ### Vault configuration [Section titled “Vault configuration”](#vault-configuration) ```json { "Vault": { "Address": "https://vault.internal:8200", "AuthMethod": "Kubernetes", "KubernetesRole": "my-backend", "KubernetesTokenPath": "/var/run/secrets/kubernetes.io/serviceaccount/token", "DatabaseMountPoint": "database", "DatabaseRoleName": "readwrite", "TransitMountPoint": "transit", "LeaseRenewalThreshold": 0.75 } } ``` | Property | Description | Default | | ----------------------- | ----------------------------------------------------- | ----------------------------------------------------- | | `Address` | Vault server URL | (required) | | `AuthMethod` | `"Kubernetes"` (production) or `"Token"` (dev only) | `"Kubernetes"` | | `KubernetesRole` | Vault role bound to the pod’s ServiceAccount | `"my-backend"` | | `KubernetesTokenPath` | Path to the mounted ServiceAccount JWT | `/var/run/secrets/kubernetes.io/serviceaccount/token` | | `DatabaseMountPoint` | Vault Database secrets engine mount point | `"database"` | | `DatabaseRoleName` | Database role for dynamic credential generation | `"readwrite"` | | `TransitMountPoint` | Vault Transit secrets engine mount point | `"transit"` | | `LeaseRenewalThreshold` | Fraction of TTL at which to renew the lease (0.0-1.0) | `0.75` | ### Authentication flow [Section titled “Authentication flow”](#authentication-flow) 1. The pod reads its ServiceAccount JWT from the mounted path. 2. Granit sends the JWT to Vault’s Kubernetes auth endpoint (`POST /auth/kubernetes/login`). 3. Vault verifies the JWT with the Kubernetes API server (TokenReview). 4. Vault returns a client token (TTL 1h, renewable). 5. `IVaultCredentialLeaseManager` renews the token automatically before expiration. ## Dynamic database credentials [Section titled “Dynamic database credentials”](#dynamic-database-credentials) Vault generates ephemeral PostgreSQL credentials with short TTLs. The credential lifecycle is fully automated: 1. **Obtain**: on application startup, Granit requests credentials from Vault’s Database engine. 2. **Active**: EF Core uses the dynamic username/password for all database operations. 3. **Renew**: when the lease reaches the renewal threshold (default 75% of TTL), `IVaultCredentialLeaseManager` renews it transparently. 4. **Revoke**: on pod shutdown, credentials are revoked immediately to minimize the exposure window. There are no static database passwords anywhere in the system. ## Transit encryption (field-level) [Section titled “Transit encryption (field-level)”](#transit-encryption-field-level) The Transit engine encrypts and decrypts data without the application ever seeing the encryption key: ```json { "Vault": { "TransitMountPoint": "transit" } } ``` Use `ITransitEncryptionService` in application code to encrypt sensitive fields (GDPR personal data, health records). The ciphertext includes the key version (`vault:v2:...`), so key rotation is transparent — old data remains readable after rotation. ### Key rotation [Section titled “Key rotation”](#key-rotation) ```bash # Rotate the Transit key (Vault CLI or API) vault write -f transit/keys/my-key/rotate ``` After rotation, new writes use the latest key version. Existing ciphertexts are decrypted using the version encoded in their prefix. ## Environment variables [Section titled “Environment variables”](#environment-variables) For non-secret configuration, use environment variables in Kubernetes: ```yaml env: - name: ASPNETCORE_ENVIRONMENT value: "Production" - name: Observability__ServiceName value: "my-backend" - name: Observability__OtlpEndpoint value: "http://otel-collector.monitoring:4317" ``` ASP.NET Core maps `__` (double underscore) to the `:` separator in configuration keys. This works for all Granit configuration sections (`Observability`, `Vault`, etc.). ## Connection strings [Section titled “Connection strings”](#connection-strings) Connection strings for PostgreSQL and Redis follow the standard `ConnectionStrings` section pattern: ```json { "ConnectionStrings": { "DefaultConnection": "Host=localhost;Database=myapp;", "Redis": "localhost:6379" } } ``` In production, the PostgreSQL connection string should **not** include username and password. Vault dynamic credentials are injected at runtime by the `IVaultCredentialLeaseManager`, which updates the connection string automatically. ## Multi-environment appsettings [Section titled “Multi-environment appsettings”](#multi-environment-appsettings) Structure your configuration files for clear environment separation: | File | Contents | Committed | | ------------------------------ | ------------------------------------------------------------ | --------- | | `appsettings.json` | Defaults, feature flags, non-sensitive settings | Yes | | `appsettings.Development.json` | Local dev overrides (localhost URLs, debug logging) | Yes | | `appsettings.Production.json` | Production-specific non-secret values (log levels, timeouts) | Yes | | `appsettings.Staging.json` | Staging-specific overrides | Yes | ## Vault monitoring [Section titled “Vault monitoring”](#vault-monitoring) Track these metrics to detect credential lifecycle issues: | Metric | Alert threshold | Description | | ----------------------- | --------------- | ------------------------------------------- | | Active lease count | > 1000 | Potential lease leak | | Token renewal failures | > 0 over 5 min | Imminent loss of access | | Seal status | `sealed = true` | Vault sealed — manual intervention required | | Storage backend latency | > 100ms | Raft storage degradation | # Deployment > Kubernetes deployment, Docker containerization, health probes, and scaling This guide covers deploying Granit applications on Kubernetes, including Docker image build, health check configuration, resource sizing, and graceful shutdown. ## Docker image [Section titled “Docker image”](#docker-image) Granit applications use multi-stage Docker builds. The runtime image is based on the .NET 10 ASP.NET runtime (Alpine variant for minimal attack surface): ```dockerfile FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build WORKDIR /src COPY . . RUN dotnet publish src/MyApp -c Release -o /app --no-restore FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS runtime WORKDIR /app COPY --from=build /app . # Run as non-root RUN adduser -D -u 1000 appuser USER appuser EXPOSE 8080 ENTRYPOINT ["dotnet", "MyApp.dll"] ``` ## Health checks with Granit.Diagnostics [Section titled “Health checks with Granit.Diagnostics”](#health-checks-with-granitdiagnostics) Granit registers three health check endpoints conforming to Kubernetes probe conventions: | Probe | Endpoint | Behavior | | ------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------- | | **Liveness** | `/health/live` | Always returns 200 — no dependency checks. Failure triggers pod restart. | | **Readiness** | `/health/ready` | Checks dependencies tagged `"readiness"` (DB, Redis). Returns 503 on Unhealthy (pod removed from LB), 200 on Healthy or Degraded. | | **Startup** | `/health/startup` | Checks dependencies tagged `"startup"`. Disables liveness/readiness while pending. | ### Registration [Section titled “Registration”](#registration) Program.cs ```csharp builder.Services.AddGranitDiagnostics(); var app = builder.Build(); app.MapGranitHealthChecks(); ``` All three endpoints are mapped with `AllowAnonymous()` because the Kubernetes kubelet cannot authenticate against application-level authorization. ### Adding custom health checks [Section titled “Adding custom health checks”](#adding-custom-health-checks) Tag your checks with `"readiness"` and/or `"startup"` to include them in the corresponding probes: ```csharp builder.Services .AddHealthChecks() .AddNpgSql(connectionString, tags: ["readiness", "startup"]) .AddRedis(redisConnectionString, tags: ["readiness"]); ``` ## Kubernetes deployment [Section titled “Kubernetes deployment”](#kubernetes-deployment) ### Probe configuration [Section titled “Probe configuration”](#probe-configuration) ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: my-backend spec: template: spec: containers: - name: app image: registry.example.com/my-backend:1.2.0 ports: - containerPort: 8080 livenessProbe: httpGet: path: /health/live port: 8080 initialDelaySeconds: 5 periodSeconds: 10 failureThreshold: 3 readinessProbe: httpGet: path: /health/ready port: 8080 initialDelaySeconds: 10 periodSeconds: 5 failureThreshold: 3 startupProbe: httpGet: path: /health/startup port: 8080 initialDelaySeconds: 5 periodSeconds: 5 failureThreshold: 30 ``` The startup probe tolerates up to 150 seconds (30 x 5s) for initial boot. This is necessary for slow startup operations: Vault credential acquisition, EF Core migrations, and cache warm-up. ### Resource limits [Section titled “Resource limits”](#resource-limits) Recommended baseline for a .NET 10 application: ```yaml resources: requests: cpu: "250m" memory: "256Mi" limits: cpu: "1000m" memory: "512Mi" ``` | Parameter | Value | Rationale | | ----------------- | ----- | --------------------------------- | | `requests.cpu` | 250m | Minimum guarantee for GC and JIT | | `requests.memory` | 256Mi | .NET heap + container overhead | | `limits.cpu` | 1000m | Burst headroom for request spikes | | `limits.memory` | 512Mi | Prevents OOM kills with margin | ### Rolling update strategy [Section titled “Rolling update strategy”](#rolling-update-strategy) ```yaml spec: strategy: type: RollingUpdate rollingUpdate: maxUnavailable: 0 maxSurge: 1 ``` * **`maxUnavailable: 0`**: no pod is removed until the replacement passes readiness. Zero-downtime deployments. * **`maxSurge: 1`**: one extra pod is created during the rollout. ## Graceful shutdown [Section titled “Graceful shutdown”](#graceful-shutdown) Graceful shutdown is critical for applications using Wolverine. Three components must drain in order: 1. **ASP.NET Core** stops accepting new HTTP requests and waits for in-flight requests to complete (5-10s). 2. **Wolverine** stops consuming from the queue, finishes running handlers, and commits remaining outbox messages to PostgreSQL (10-30s). 3. **Vault lease revocation** revokes dynamic credentials to minimize the exposure window (1-2s). ### terminationGracePeriodSeconds [Section titled “terminationGracePeriodSeconds”](#terminationgraceperiodseconds) ```yaml spec: template: spec: terminationGracePeriodSeconds: 60 ``` 60 seconds is appropriate for standard applications. Increase to 120 if long-running batch operations (e.g., data migrations) are possible. If the grace period is exceeded, Kubernetes sends SIGKILL. ## Connection pooling with PgBouncer [Section titled “Connection pooling with PgBouncer”](#connection-pooling-with-pgbouncer) For multi-tenant applications with many concurrent connections, deploy PgBouncer as a sidecar: ```yaml containers: - name: pgbouncer image: bitnami/pgbouncer:1.22 ports: - containerPort: 6432 env: - name: POSTGRESQL_HOST value: "pg-primary.database" - name: POSTGRESQL_PORT value: "5432" - name: PGBOUNCER_POOL_MODE value: "transaction" - name: PGBOUNCER_MAX_CLIENT_CONN value: "200" - name: PGBOUNCER_DEFAULT_POOL_SIZE value: "20" ``` The application connects to `localhost:6432` instead of PostgreSQL directly. Transaction-level pooling (`pool_mode: transaction`) is compatible with Vault dynamic credentials because the pooler does not maintain persistent connections. ## Secrets injection [Section titled “Secrets injection”](#secrets-injection) Secrets are injected via **Vault Agent Injector** or **CSI Secret Store Driver**: ```yaml # Vault Agent Injector (annotations) annotations: vault.hashicorp.com/agent-inject: "true" vault.hashicorp.com/role: "my-backend" vault.hashicorp.com/agent-inject-secret-db: "database/creds/my-readonly" ``` No plaintext secrets Never store secrets in Kubernetes `ConfigMap` or `Secret` resources in plaintext. Use Vault for centralized secret management. This is a hard requirement for ISO 27001 compliance. ## Scaling considerations [Section titled “Scaling considerations”](#scaling-considerations) | Factor | Guidance | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Horizontal scaling | Stateless by design — scale replicas freely. Wolverine uses durable PostgreSQL queues, so messages are not lost during scale events. | | Database connections | Each replica opens its own connection pool. Use PgBouncer to limit total connections to PostgreSQL. | | Redis | All replicas share the same Redis instance for distributed cache. HybridCache (L1 in-process + L2 Redis) reduces Redis load. | | Background jobs | `Granit.BackgroundJobs` uses Wolverine scheduling. Jobs are durable and survive pod restarts. Only one replica executes each scheduled job (leader election via PostgreSQL advisory locks). | | Multi-tenancy | Tenant isolation is enforced at the query level (EF Core global filters). No per-tenant infrastructure is required unless data sovereignty demands physical separation. | # Frontend CI/CD > GitHub Actions CI pipeline, quality gates, and release workflow for granit-front. ## Pipeline overview [Section titled “Pipeline overview”](#pipeline-overview) The granit-front pipeline runs on pull requests, `develop`, `main`, and semantic tags (`v*.*.*`). ``` flowchart LR Q[quality] --> S[security] --> T[test] --> A[analysis] ``` | Stage | Jobs | Description | | ------------ | ----------------------------- | --------------------------------------------- | | **quality** | `lint`, `typecheck` | ESLint (0 warnings) + TypeScript strict | | **security** | `gitleaks`, `codeql`, `trivy` | Secret scanning, SAST, vulnerability scanning | | **test** | `test` | Vitest with v8 coverage | | **analysis** | `audit:npm`, `sonarqube` | Dependency audit + SonarQube | ### Runtime environment [Section titled “Runtime environment”](#runtime-environment) | Parameter | Value | | --------------- | -------------------------------------- | | Image | `node:24-bookworm-slim` | | Package manager | pnpm 10 (via corepack) | | Cache | `.pnpm-store/` (key: `pnpm-lock.yaml`) | | Husky hooks | Disabled in CI (`HUSKY=0`) | ## Quality jobs [Section titled “Quality jobs”](#quality-jobs) ### Lint [Section titled “Lint”](#lint) ```bash pnpm lint ``` ESLint with `--max-warnings 0`. Zero warnings tolerated. Notable rules: * `no-console` as error (except in `@granit/logger`) * `@typescript-eslint/consistent-type-imports` — `import type` required * `import/order` — imports sorted by group ### Typecheck [Section titled “Typecheck”](#typecheck) ```bash pnpm tsc # pnpm -r exec -- tsc --noEmit ``` TypeScript strict on all packages: no implicit `any`, no unused variables, no unused parameters. ## Security jobs [Section titled “Security jobs”](#security-jobs) **Secret detection** — Gitleaks via GitHub Actions. A detected secret **blocks the pipeline** (`continue-on-error: false`). **SAST** — Static analysis via CodeQL. CodeQL is **blocking** (`continue-on-error: false`). **Vulnerability scanning** — Trivy scans for known vulnerabilities. Trivy is **blocking** (`continue-on-error: false`). ## Test job [Section titled “Test job”](#test-job) ```bash pnpm test:coverage ``` Vitest single-run with coverage. Generated artifacts: | Artifact | Retention | Usage | | --------------------------------- | --------- | -------------------------- | | `coverage/cobertura-coverage.xml` | 1 week | PR coverage widget | | `coverage/` (HTML + lcov) | 1 week | Local browsing + SonarQube | ## Analysis jobs [Section titled “Analysis jobs”](#analysis-jobs) ### npm audit [Section titled “npm audit”](#npm-audit) ```bash pnpm audit --audit-level moderate ``` Checks known vulnerabilities in dependencies (moderate and above). `continue-on-error: true` — informational, does not block the pipeline. ### SonarQube [Section titled “SonarQube”](#sonarqube) Conditional — runs only when `SONAR_HOST_URL` and `SONAR_TOKEN` are set. * **Sources**: `packages/` * **Coverage**: `coverage/lcov.info` * **Exclusions**: `**/*.test.ts`, `**/*.test.tsx`, `**/*.d.ts` * `continue-on-error: true` ## Branch workflow [Section titled “Branch workflow”](#branch-workflow) ``` gitgraph commit id: "main" branch develop commit id: "feat: logger" branch feature/auth commit id: "feat: auth init" commit id: "feat: auth context" checkout develop merge feature/auth branch release/1.0 commit id: "chore: version" checkout main merge release/1.0 tag: "v1.0.0" checkout develop merge release/1.0 ``` | Branch | Role | | ----------- | ---------------------------------- | | `main` | Production — direct push forbidden | | `develop` | Continuous integration | | `feature/*` | Feature development | | `release/*` | Release preparation | | `hotfix/*` | Urgent fixes | ## Pre-commit hooks [Section titled “Pre-commit hooks”](#pre-commit-hooks) Local Git hooks (via Husky) run automatically: | Hook | Command | | ------------ | ----------------------------- | | `pre-commit` | `pnpm lint && pnpm tsc` | | `commit-msg` | `pnpm exec commitlint --edit` | Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `docs:`, `chore:`, `refactor:`, `test:`. ## Release process [Section titled “Release process”](#release-process) Releases follow semantic versioning (`vMAJOR.MINOR.PATCH`): 1. Create a `release/X.Y` branch from `develop` 2. Verify the pipeline passes (lint + tsc + tests + security) 3. Merge into `main` via PR (1 approval minimum) 4. Tag on `main`: `vX.Y.Z` 5. Merge `main` back into `develop` ## See also [Section titled “See also”](#see-also) * [Frontend npm Registry](/operations/frontend-npm-registry/) — package publication * [Frontend Testing](/guides/frontend-testing/) — test conventions * [Backend CI/CD](/operations/ci-cd/) — .NET pipeline # Frontend npm Registry > Hybrid publication strategy for @granit/* packages — local source-direct and GitHub Packages npm registry. ## Overview [Section titled “Overview”](#overview) The `@granit/*` packages are published on the **GitHub Packages npm registry**. A hybrid strategy keeps local development performant (hot-reload) while supporting Docker builds and standalone CI/CD. ``` graph LR subgraph "Local development" DEV["guava-admin / guava-front"] DEV -->|"link:"| SRC["granit-front source
(TypeScript)"] end subgraph "Docker / CI" CI[Dockerfile] CI -->|"npm install"| REG["GitHub Packages
(compiled packages)"] end subgraph "Publication" TAG["Tag vX.Y.Z"] -->|CI pipeline| BUILD["tsup build
(ESM + .d.ts)"] BUILD -->|"pnpm publish"| REG end ``` ## Hybrid strategy [Section titled “Hybrid strategy”](#hybrid-strategy) | Context | Resolution | Source | Hot-reload | | ----------------- | ---------------------------- | ----------------------------- | ---------- | | Local development | `link:` + Vite aliases | TypeScript source | Yes | | Docker / CI | GitHub Packages npm registry | Compiled JavaScript + `.d.ts` | No | ### Local development [Section titled “Local development”](#local-development) Applications use `link:` in `package.json` to point to TypeScript sources: ```json { "dependencies": { "@granit/logger": "link:../../../granit-front/packages/@granit/logger" } } ``` Combined with Vite aliases, this provides instant hot-reload when modifying framework code. ### Docker / CI [Section titled “Docker / CI”](#docker--ci) In Docker, the `granit-front` directory does not exist. The Dockerfile: 1. Copies `.npmrc` (scope `@granit` → GitHub Packages registry) 2. Replaces `link:` dependencies with `*` (registry resolution) 3. Injects the authentication token via `--build-arg NPM_TOKEN` 4. Installs compiled packages from the registry ## Publication [Section titled “Publication”](#publication) ### Versioning [Section titled “Versioning”](#versioning) All packages share the same version, aligned with Git tags on the granit-front project. Semantic versioning (`vMAJOR.MINOR.PATCH`). ### Automatic publication (CI) [Section titled “Automatic publication (CI)”](#automatic-publication-ci) Publication is triggered by a **Git tag** matching `vX.Y.Z`: ```bash git tag v0.1.0 git push origin v0.1.0 ``` The CI pipeline runs: 1. `quality` — lint + typecheck 2. `test` — unit tests 3. `build` — `tsup` (ESM + `.d.ts`) for each package 4. `publish` — `pnpm -r publish` to the GitHub Packages registry Authentication uses `GITHUB_TOKEN` (automatic, no secret to configure). ### Manual publication [Section titled “Manual publication”](#manual-publication) For manual publication (not recommended in production): ```bash # Create a personal access token (PAT) in GitHub > Settings > Personal access tokens # Scope: write:packages pnpm build pnpm -r publish --no-git-checks --access restricted ``` ## Consumer configuration [Section titled “Consumer configuration”](#consumer-configuration) ### `.npmrc` [Section titled “.npmrc”](#npmrc) Each consuming application needs an `.npmrc`: ```ini @granit:registry=https://npm.pkg.github.com ``` ### Conditional Vite aliases [Section titled “Conditional Vite aliases”](#conditional-vite-aliases) The `@granit/*` aliases in `vite.config.ts` are **conditional** — they only apply when the local `granit-front` directory exists: ```typescript import fs from 'fs'; const GRANIT = path.resolve(__dirname, '../../../granit-front/packages/@granit'); const useLocalGranit = fs.existsSync(GRANIT); // Local dev: aliases → TypeScript source (hot-reload) // Docker/CI: no aliases → resolved via node_modules (registry) ``` ### Docker build [Section titled “Docker build”](#docker-build) ```bash docker build \ --build-arg NPM_TOKEN= \ -t guava-admin . ``` In CI, `GITHUB_TOKEN` is used automatically. ## Package build configuration [Section titled “Package build configuration”](#package-build-configuration) ### tsup [Section titled “tsup”](#tsup) Each package is compiled with **tsup**: * **Format**: ESM only * **Output**: `dist/index.js` + `dist/index.d.ts` * **Externals**: `@granit/*` (inter-packages), `peerDependencies` ### publishConfig [Section titled “publishConfig”](#publishconfig) The `package.json` of each package uses `publishConfig` to separate local exports (source) from published exports (compiled): ```json { "exports": { ".": "./src/index.ts" }, "publishConfig": { "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } }, "registry": "https://npm.pkg.github.com" } } ``` * **Locally**: `exports` points to TypeScript source * **Published**: `publishConfig.exports` overrides and points to `dist/` ## Troubleshooting [Section titled “Troubleshooting”](#troubleshooting) ### Package not found on the registry [Section titled “Package not found on the registry”](#package-not-found-on-the-registry) ```bash npm view @granit/logger \ --registry=https://npm.pkg.github.com ``` Or check in GitHub: **granit-fx/granit-front > Packages**. ### Authentication error (401/403) [Section titled “Authentication error (401/403)”](#authentication-error-401403) * Verify `.npmrc` contains the correct `_authToken` * In CI: `GITHUB_TOKEN` is automatic, no configuration needed * Locally: use a personal access token (PAT) with `read:packages` scope ### Docker build fails on @granit/\* packages [Section titled “Docker build fails on @granit/\* packages”](#docker-build-fails-on-granit-packages) 1. Verify `--build-arg NPM_TOKEN=xxx` is passed to `docker build` 2. Verify packages are published on the registry 3. Verify `.npmrc` is copied in the `deps` stage (not excluded by `.dockerignore`) ## See also [Section titled “See also”](#see-also) * [Frontend CI/CD](/operations/frontend-ci-cd/) — pipeline and release workflow * [Frontend Quick Start](/guides/frontend-quick-start/) — local setup # Observability > Production observability with Serilog, OpenTelemetry, and the Grafana LGTM stack This guide covers the production observability setup for Granit applications: structured logging with Serilog, distributed tracing and metrics with OpenTelemetry, and visualization with the Grafana LGTM stack (Loki, Grafana, Tempo, Mimir). ## Architecture [Section titled “Architecture”](#architecture) Granit exports all three observability signals via OTLP to a self-hosted Grafana stack: | Signal | Library | Collector destination | Storage | | ------- | ------------------------ | ----------------------- | ------- | | Logs | Serilog 9+ (OTLP sink) | OpenTelemetry Collector | Loki | | Traces | OpenTelemetry .NET 1.11+ | OpenTelemetry Collector | Tempo | | Metrics | OpenTelemetry .NET 1.11+ | OpenTelemetry Collector | Mimir | All telemetry flows through the OpenTelemetry Collector, which routes signals to the appropriate backend. The entire stack is self-hosted on European infrastructure — no telemetry leaves the EU. ## Configuration (Granit.Observability) [Section titled “Configuration (Granit.Observability)”](#configuration-granitobservability) A single call to `AddGranitObservability()` configures Serilog and OpenTelemetry: Program.cs ```csharp builder.AddGranitObservability(); ``` This registers: * Serilog with structured logging, console output, and OTLP export * OpenTelemetry tracing with ASP.NET Core, HttpClient, and EF Core instrumentation * OpenTelemetry metrics with ASP.NET Core and HttpClient instrumentation * Automatic enrichment of all log entries with service metadata ### Configuration options [Section titled “Configuration options”](#configuration-options) ```json { "Observability": { "ServiceName": "my-backend", "ServiceVersion": "1.2.0", "ServiceNamespace": "my-company", "Environment": "production", "OtlpEndpoint": "http://otel-collector.monitoring:4317", "EnableTracing": true, "EnableMetrics": true } } ``` | Property | Description | Default | | ------------------ | --------------------------------------------------------------- | ------------------------- | | `ServiceName` | Service identifier in all telemetry signals | `"unknown-service"` | | `ServiceVersion` | Service version | `"0.0.0"` | | `ServiceNamespace` | Logical grouping of services | `"my-company"` | | `Environment` | Deployment environment (`production`, `staging`, `development`) | `"development"` | | `OtlpEndpoint` | OpenTelemetry Collector gRPC endpoint | `"http://localhost:4317"` | | `EnableTracing` | Enable trace export via OTLP | `true` | | `EnableMetrics` | Enable metrics export via OTLP | `true` | ## Structured logging (Loki) [Section titled “Structured logging (Loki)”](#structured-logging-loki) ### Automatic enrichment [Section titled “Automatic enrichment”](#automatic-enrichment) Every log entry is automatically enriched with the following properties: | Property | Source | Description | | ---------------- | ---------------------- | ---------------------------------------------- | | `ServiceName` | `ObservabilityOptions` | Service identifier | | `ServiceVersion` | `ObservabilityOptions` | Deployed version | | `Environment` | `ObservabilityOptions` | Deployment environment | | `TenantId` | `ICurrentTenant` | Active tenant (if multi-tenancy is configured) | | `UserId` | `ICurrentUserService` | Authenticated user | | `TraceId` | `Activity.Current` | OpenTelemetry trace identifier | | `SpanId` | `Activity.Current` | OpenTelemetry span identifier | | `MachineName` | System | Kubernetes pod name | ### LogQL queries [Section titled “LogQL queries”](#logql-queries) Useful queries for production troubleshooting in Grafana: ```logql # Errors in the last 24 hours for a service {service_name="my-backend"} | json | Level = "Error" # Slow requests (> 500ms) {service_name="my-backend"} | json | RequestDuration > 500 # Activity for a specific tenant {service_name="my-backend"} | json | TenantId = "tenant-123" # Audit trail: user access log (ISO 27001) {service_name="my-backend"} | json | UserId = "john.doe" | Level = "Error" # Correlate logs with a specific trace {service_name="my-backend"} | json | TraceId = "abc123def456" ``` ### Source-generated logging [Section titled “Source-generated logging”](#source-generated-logging) Granit requires `[LoggerMessage]` source-generated logging throughout — never use string interpolation in log calls: ```csharp public static partial class LogMessages { [LoggerMessage(Level = LogLevel.Information, Message = "Processing order {OrderId} for tenant {TenantId}")] public static partial void ProcessingOrder(this ILogger logger, Guid orderId, Guid tenantId); } ``` This eliminates boxing allocations and enables compile-time validation of log message templates. ## Distributed tracing (Tempo) [Section titled “Distributed tracing (Tempo)”](#distributed-tracing-tempo) ### Automatic instrumentation [Section titled “Automatic instrumentation”](#automatic-instrumentation) `AddGranitObservability()` instruments the following automatically: * **ASP.NET Core**: incoming HTTP requests (with exception recording) * **HttpClient**: outgoing HTTP requests * **EF Core**: SQL queries * **Wolverine**: message handlers (via `TraceContextBehavior` — context propagation across async boundaries) * **Redis**: cache operations (when StackExchange.Redis instrumentation is added) Health check endpoints (`/health/*`) are excluded from tracing to reduce noise. ### Custom activity sources [Section titled “Custom activity sources”](#custom-activity-sources) Granit modules register their own `ActivitySource` instances via `GranitActivitySourceRegistry`. These are automatically picked up by the OpenTelemetry tracer configuration — no manual `AddSource()` calls needed for Granit modules. ### Log-trace correlation [Section titled “Log-trace correlation”](#log-trace-correlation) In Grafana, configure a **data source correlation** between Loki and Tempo. Clicking a `TraceId` in a log entry opens the corresponding trace in Tempo. This provides end-to-end request visibility across services. ## Metrics (Mimir) [Section titled “Metrics (Mimir)”](#metrics-mimir) ### Exposed metrics [Section titled “Exposed metrics”](#exposed-metrics) | Metric | Type | Description | | -------------------------------------- | ------------- | --------------------------- | | `http_server_request_duration_seconds` | Histogram | HTTP request duration | | `http_server_active_requests` | UpDownCounter | In-flight HTTP requests | | `db_client_operation_duration_seconds` | Histogram | Database operation duration | | `dotnet_gc_collections_total` | Counter | .NET GC collection count | | `dotnet_process_memory_bytes` | Gauge | Process memory usage | ### Recommended Grafana dashboards [Section titled “Recommended Grafana dashboards”](#recommended-grafana-dashboards) Build these dashboards for comprehensive production visibility: 1. **Service overview**: request rate, error rate (4xx/5xx), p50/p95/p99 latency 2. **Database**: SQL query duration, connection pool utilization, slow queries 3. **Cache**: hit ratio, Redis latency, evictions 4. **Wolverine**: messages processed/s, error rate, queue depth, dead letter count 5. **Infrastructure**: CPU, memory, GC pressure, thread count ## Alerting [Section titled “Alerting”](#alerting) ### Recommended alert rules [Section titled “Recommended alert rules”](#recommended-alert-rules) | Alert | Condition | Severity | | ---------------------------- | ----------------------------------------------------- | -------- | | High HTTP error rate | `rate(http_5xx) / rate(http_total) > 0.05` over 5 min | Critical | | High P99 latency | `p99(http_duration) > 2s` over 5 min | Warning | | Vault lease renewal failure | Log `"Vault lease renewal failed"` | Critical | | DB connection pool saturated | `db_pool_active / db_pool_max > 0.9` | Warning | | Wolverine dead letter queue | `wolverine_dead_letter_count > 0` | Warning | ### Notification channels [Section titled “Notification channels”](#notification-channels) | Severity | Channel | Response | | -------- | -------------------- | ---------------------------------- | | Critical | PagerDuty / OpsGenie | On-call SRE paged immediately | | Warning | Slack `#ops-alerts` | Investigated within business hours | | Info | Weekly email digest | Reviewed in operations meeting | ## Data retention [Section titled “Data retention”](#data-retention) | Signal | Hot retention | Cold retention | ISO 27001 requirement | | ------- | ------------- | -------------- | ----------------------------- | | Logs | 90 days | 3 years | 3 years minimum (audit trail) | | Traces | 30 days | — | Not required | | Metrics | 1 year | — | Not required | Log retention compliance ISO 27001 requires audit trail logs (data access, authentication events) to be retained for a minimum of 3 years. Configure Loki retention rules accordingly. Use cold storage (object storage) for logs beyond the 90-day hot window. ## OpenTelemetry Collector configuration [Section titled “OpenTelemetry Collector configuration”](#opentelemetry-collector-configuration) A minimal Collector configuration for routing Granit signals: ```yaml receivers: otlp: protocols: grpc: endpoint: 0.0.0.0:4317 exporters: loki: endpoint: http://loki:3100/loki/api/v1/push otlp/tempo: endpoint: http://tempo:4317 tls: insecure: true prometheusremotewrite: endpoint: http://mimir:9009/api/v1/push service: pipelines: logs: receivers: [otlp] exporters: [loki] traces: receivers: [otlp] exporters: [otlp/tempo] metrics: receivers: [otlp] exporters: [prometheusremotewrite] ``` # Production checklist > Go-live verification for security, GDPR, ISO 27001, and operational readiness This checklist covers the mandatory verifications before deploying a Granit application to production. Every item maps to a compliance requirement (GDPR, ISO 27001) or an operational best practice. ## Security hardening [Section titled “Security hardening”](#security-hardening) * [ ] No plaintext secrets in code, configuration files, or unencrypted environment variables * [ ] HashiCorp Vault or Azure Key Vault configured and reachable * [ ] PostgreSQL credentials are dynamic via Vault or Key Vault Secrets (no static passwords) * [ ] HTTPS enforced — no HTTP endpoints in production * [ ] JWT Bearer configured with `RequireHttpsMetadata: true` * [ ] RBAC permissions defined and assigned per tenant * [ ] No debug or diagnostic endpoints exposed publicly * [ ] CORS policy restricted to known origins (`Granit.Cors`) * [ ] Rate limiting configured for public-facing endpoints * [ ] TLS between all internal components (application to database, application to Redis, application to Vault) ## ISO 27001 compliance [Section titled “ISO 27001 compliance”](#iso-27001-compliance) * [ ] Audit trail enabled: `AuditedEntityInterceptor` registered in every DbContext * [ ] `CreatedBy`, `ModifiedBy` populated automatically via `ICurrentUserService` * [ ] Log retention configured to 3 years minimum in Loki (cold storage for archive) * [ ] Encryption at rest enabled for sensitive data (Vault Transit or Azure Key Vault via `IStringEncryptionService`) * [ ] Encryption in transit: TLS between all components * [ ] Access traceability: every request associated with `UserId` and `TenantId` in logs * [ ] Authentication events logged (login, logout, token refresh, failed attempts) ## GDPR compliance [Section titled “GDPR compliance”](#gdpr-compliance) * [ ] Soft delete enabled for entities containing personal data (`FullAuditedEntity`) * [ ] `SoftDeleteInterceptor` registered in the DbContext * [ ] EF Core global query filters active (deleted entities hidden by default via `ApplyGranitConventions`) * [ ] Data minimization verified: no superfluous fields in entities * [ ] Pseudonymization: personal data encrypted via Vault Transit * [ ] Right to erasure: process documented and tested * [ ] Data processing records maintained (`Granit.Privacy`) ## Database [Section titled “Database”](#database) * [ ] EF Core migrations applied and tested in a staging environment first * [ ] Indexes created on frequently filtered columns (`TenantId`, `IsDeleted`, `CreatedAt`) * [ ] Connection pooling configured (PgBouncer sidecar for multi-tenant applications) * [ ] Automated backups configured and restoration tested * [ ] Dynamic Vault credentials functional (lease renewal verified end-to-end) * [ ] Connection string does not contain static credentials ## Observability [Section titled “Observability”](#observability) * [ ] `Observability:OtlpEndpoint` configured to point at the OpenTelemetry Collector * [ ] `Observability:ServiceName` set to a meaningful service identifier * [ ] `Observability:ServiceVersion` set to the deployed version * [ ] Grafana dashboards provisioned (HTTP, database, cache, Wolverine) * [ ] Alert rules configured (error rate, latency, Vault failures, dead letter queue) * [ ] Log-trace correlation verified: Loki to Tempo link functional in Grafana * [ ] All telemetry routed to European infrastructure (no US-based collectors) ## Kubernetes [Section titled “Kubernetes”](#kubernetes) * [ ] Liveness probe configured (`/health/live`) * [ ] Readiness probe configured (`/health/ready`) * [ ] Startup probe configured (`/health/startup`) * [ ] Resource limits defined (CPU and memory requests/limits) * [ ] `terminationGracePeriodSeconds` set to 60s minimum * [ ] Rolling update strategy: `maxUnavailable: 0` * [ ] Secrets injected via Vault Agent (no plaintext Kubernetes Secrets) * [ ] Pod runs as non-root user (`securityContext.runAsNonRoot: true`) * [ ] Network policies restrict pod-to-pod communication to required paths ## Cache (Redis) [Section titled “Cache (Redis)”](#cache-redis) * [ ] Redis accessible and password-protected (credentials from Vault) * [ ] `[CacheEncrypted]` applied on types containing sensitive data * [ ] TTL configured to prevent memory exhaustion * [ ] Stampede protection active (Granit default configuration) ## Messaging (Wolverine) [Section titled “Messaging (Wolverine)”](#messaging-wolverine) * [ ] PostgreSQL transport configured (`Granit.Wolverine.Postgresql`) * [ ] Outbox tables created (`wolverine_outbox_*`) * [ ] Context propagation verified (`TenantId`, `UserId`, `traceparent` flow through message handlers) * [ ] Dead letter queue monitored (alert rule if non-empty) * [ ] Graceful shutdown verified: all handlers complete before SIGKILL ## Performance [Section titled “Performance”](#performance) * [ ] Load tests executed with a realistic traffic profile * [ ] Performance baseline established (p50, p95, p99 latency) * [ ] No N+1 query patterns identified in EF Core traces * [ ] Cache hit ratio acceptable (> 80% for frequently accessed data) * [ ] Database query plans reviewed for critical paths ## Operational readiness [Section titled “Operational readiness”](#operational-readiness) * [ ] Runbook written covering operational procedures (startup, shutdown, incident response) * [ ] Deployment architecture documented (network diagram, component inventory) * [ ] On-call contacts defined and notification channels tested * [ ] Rollback procedure documented and tested * [ ] Change management process followed (MR approved, CI passed, staging validated) ## Post-deployment verification [Section titled “Post-deployment verification”](#post-deployment-verification) After the first production deployment, verify: * [ ] Health check endpoints return 200 (`/health/live`, `/health/ready`, `/health/startup`) * [ ] Logs appear in Grafana/Loki with correct `ServiceName` and `Environment` * [ ] Traces appear in Grafana/Tempo for HTTP requests * [ ] Metrics appear in Grafana/Mimir (request rate, error rate) * [ ] Vault lease renewal (or Azure Key Vault secret rotation) succeeds (check logs for confirmation) * [ ] At least one end-to-end request completes successfully (smoke test) # Reference > Complete reference documentation for all 97 Granit packages This section provides detailed reference documentation for every Granit module. Each module page documents the full package family (abstractions, providers, EF Core integration, endpoints), configuration options, public API surface, and provider comparison. ## Module categories [Section titled “Module categories”](#module-categories) | Category | Modules | Description | | ------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | | Core & Utilities | [Core & Utilities](./modules/core/) | Foundation types, module system, Timing, Guids, Validation | | Security | [Security & Identity](./modules/security/), [Privacy](./modules/privacy/), [Vault & Encryption](./modules/vault-encryption/) | Authentication, authorization, encryption, GDPR | | Identity | [Identity](./modules/identity/) | Keycloak/EntraID/Cognito integration, user cache | | Data & Persistence | [Persistence](./modules/persistence/), [Caching](./modules/caching/), [Multi-Tenancy](./modules/multi-tenancy/) | EF Core interceptors, HybridCache, tenant isolation | | Settings & Features | [Settings & Features](./modules/settings-features/) | Application settings, feature flags, reference data | | API & Web | [API & Web](./modules/api-web/) | Versioning, OpenAPI docs, idempotency, CORS, cookies | | Messaging | [Wolverine](./modules/wolverine/), [Webhooks](./modules/webhooks/), [Notifications](./modules/notifications/) | Message bus, outbox, webhooks, 6-channel notifications | | Audit | [Timeline](./modules/timeline/) | Entity activity stream (chatter), follow/notify | | Documents | [Templating & DocumentGeneration](./modules/templating/) | Scriban templates, HTML-to-PDF, Excel generation | | Data Exchange | [DataExchange](./modules/data-exchange/) | Import pipeline (CSV, Excel), export presets | | Workflow | [Workflow](./modules/workflow/) | FSM engine, publication lifecycle | | Diagnostics | [Observability & Diagnostics](./modules/observability/) | Serilog, OpenTelemetry, health checks | | Storage | [BlobStorage & Imaging](./modules/blob-storage/) | Multi-provider storage (S3, Azure, FileSystem, Database), image processing | | Scheduling | [BackgroundJobs](./modules/background-jobs/) | Recurring and delayed jobs (Wolverine + Cronos) | | Localization | [Localization](./modules/localization/) | i18n (17 cultures), source-generated keys | ## Cross-cutting references [Section titled “Cross-cutting references”](#cross-cutting-references) * [Configuration Keys](./configuration-keys/) — all appsettings sections and Options classes * [HTTP Conventions](./http-conventions/) — status codes, Problem Details, DTO naming * [Dependency Graph](./dependency-graph/) — package relationships and module dependencies * [Cloud Providers](./cloud-providers/) — packages by cloud provider (AWS, Azure, Google Cloud) * [Provider Compatibility](./provider-compatibility/) — database, cache, storage support matrix * [Tech Stack](./tech-stack/) — third-party libraries, licenses, and ADR links # Cloud Providers > All Granit packages organized by cloud provider — AWS, Azure, and Google Cloud Granit is cloud-agnostic by design. Each module defines provider-neutral abstractions (`IIdentityProvider`, `IBlobStorageProvider`, `IEncryptionService`, etc.) and ships dedicated provider packages for major cloud platforms. Switch providers by changing a single NuGet reference and DI registration — no application code changes needed. ## At a glance [Section titled “At a glance”](#at-a-glance) | Domain | [AWS](#aws) | [Azure](#azure) | [Google Cloud](#google-cloud) | [Alibaba Cloud](#alibaba-cloud) | | --------------------------- | :---------: | :-------------: | :---------------------------: | :-----------------------------: | | Authentication | ✓ | ✓ | ✓ | planned | | Identity | ✓ | ✓ | ✓ | planned | | Vault / Encryption | ✓ | ✓ | ✓ | planned | | Blob Storage | ✓ | ✓ | ✓ | planned | | Notifications — Email | ✓ | ✓ | ✗ | planned | | Notifications — SMS | ✓ | ✓ | ✗ | planned | | Notifications — Mobile Push | ✓ | ✓ | ✓ | planned | | **Total packages** | **7** | **7** | **5** | **0** | ## Setup [Section titled “Setup”](#setup) * AWS ```csharp // Program.cs — AWS provider stack builder.AddGranitAuthenticationCognito(); builder.AddGranitIdentityCognito(); builder.AddGranitVaultAws(); builder.AddGranitBlobStorageS3(); builder.AddGranitNotificationsEmailAwsSes(); builder.AddGranitNotificationsSmsAwsSns(); builder.AddGranitNotificationsMobilePushAwsSns(); ``` * Azure ```csharp // Program.cs — Azure provider stack builder.AddGranitAuthenticationEntraId(); builder.AddGranitIdentityEntraId(); builder.AddGranitVaultAzure(); builder.AddGranitBlobStorageAzureBlob(); builder.AddGranitNotificationsEmailAzureCommunicationServices(); builder.AddGranitNotificationsSmsAzureCommunicationServices(); builder.AddGranitNotificationsMobilePushAzureNotificationHubs(); ``` * Google Cloud ```csharp // Program.cs — Google Cloud provider stack builder.AddGranitAuthenticationGoogleCloud(); builder.AddGranitIdentityGoogleCloud(); builder.AddGranitVaultGoogleCloud(); builder.AddGranitBlobStorageGoogleCloud(); builder.AddGranitNotificationsMobilePushGoogleFcm(); ``` *** ## AWS [Section titled “AWS”](#aws) Amazon Web Services packages use the official AWS SDK for .NET. | Package | Module | What it does | | ---------------------------------------- | -------------- | ------------------------------------------------------------------------------------ | | `Granit.Authentication.Cognito` | Authentication | JWT validation + claims transformation for Cognito User Pools | | `Granit.Identity.Cognito` | Identity | `IIdentityProvider` via Cognito User Pool Admin API | | `Granit.Vault.Aws` | Vault | `IEncryptionService` via AWS KMS + dynamic credentials via Secrets Manager | | `Granit.BlobStorage.S3` | Blob Storage | `IBlobStorageProvider` via S3-compatible API (also works with MinIO, Scaleway, etc.) | | `Granit.Notifications.Email.AwsSes` | Notifications | `IEmailSender` via Amazon Simple Email Service | | `Granit.Notifications.Sms.AwsSns` | Notifications | `ISmsSender` via Amazon SNS | | `Granit.Notifications.MobilePush.AwsSns` | Notifications | `IMobilePushSender` via Amazon SNS platform applications | **See also:** [Identity](/reference/modules/identity/) — [Vault & Encryption](/reference/modules/vault-encryption/) — [Blob Storage](/reference/modules/blob-storage/) — [Notifications](/reference/modules/notifications/) *** ## Azure [Section titled “Azure”](#azure) Microsoft Azure packages use `DefaultAzureCredential` for authentication (Managed Identity, Azure CLI, etc.). | Package | Module | What it does | | ------------------------------------------------------- | -------------- | ------------------------------------------------------------------------ | | `Granit.Authentication.EntraId` | Authentication | JWT validation + claims transformation for Microsoft Entra ID (Azure AD) | | `Granit.Identity.EntraId` | Identity | `IIdentityProvider` via Microsoft Graph API | | `Granit.Vault.Azure` | Vault | `IEncryptionService` via Azure Key Vault | | `Granit.BlobStorage.AzureBlob` | Blob Storage | `IBlobStorageProvider` via Azure Blob Storage | | `Granit.Notifications.Email.AzureCommunicationServices` | Notifications | `IEmailSender` via Azure Communication Services | | `Granit.Notifications.Sms.AzureCommunicationServices` | Notifications | `ISmsSender` via Azure Communication Services | | `Granit.Notifications.MobilePush.AzureNotificationHubs` | Notifications | `IMobilePushSender` via Azure Notification Hubs | **See also:** [Identity](/reference/modules/identity/) — [Vault & Encryption](/reference/modules/vault-encryption/) — [Blob Storage](/reference/modules/blob-storage/) — [Notifications](/reference/modules/notifications/) *** ## Google Cloud [Section titled “Google Cloud”](#google-cloud) Google Cloud packages use Application Default Credentials (ADC) or service account JSON keys. | Package | Module | What it does | | ------------------------------------------- | -------------- | ----------------------------------------------------------------------------------------- | | `Granit.Authentication.GoogleCloud` | Authentication | JWT validation + claims transformation for Google Cloud Identity Platform (Firebase Auth) | | `Granit.Identity.GoogleCloud` | Identity | `IIdentityProvider` via Firebase Admin SDK | | `Granit.Vault.GoogleCloud` | Vault | `IEncryptionService` via Cloud KMS + dynamic credentials via Secret Manager | | `Granit.BlobStorage.GoogleCloud` | Blob Storage | `IBlobStorageProvider` via Google Cloud Storage | | `Granit.Notifications.MobilePush.GoogleFcm` | Notifications | `IMobilePushSender` via Firebase Cloud Messaging | **See also:** [Identity](/reference/modules/identity/) — [Vault & Encryption](/reference/modules/vault-encryption/) — [Blob Storage](/reference/modules/blob-storage/) — [Notifications](/reference/modules/notifications/) *** ## Alibaba Cloud [Section titled “Alibaba Cloud”](#alibaba-cloud) Planned Alibaba Cloud provider packages are planned but not yet implemented. Alibaba Cloud offers mature equivalents for all domains: RAM/IDaaS (identity), KMS (encryption), OSS (S3-compatible storage), DirectMail (email), SMS Service, and Push Notifications. Contributions welcome — see [CONTRIBUTING.md](https://github.com/granit-fx/granit-dotnet/blob/main/CONTRIBUTING.md). | Domain | Alibaba Cloud service | Status | | --------------------------- | --------------------- | ------- | | Authentication | IDaaS | planned | | Identity | RAM + IDaaS | planned | | Vault / Encryption | KMS | planned | | Blob Storage | OSS (S3-compatible) | planned | | Notifications — Email | DirectMail | planned | | Notifications — SMS | Short Message Service | planned | | Notifications — Mobile Push | Push Notifications | planned | *** ## Cloud-agnostic providers [Section titled “Cloud-agnostic providers”](#cloud-agnostic-providers) In addition to cloud-specific packages, Granit offers providers that work with any cloud platform: | Package | Channel(s) | Service | | ------------------------------------- | ---------------------- | --------------------------- | | `Granit.Notifications.Brevo` | Email + SMS + WhatsApp | Brevo (formerly Sendinblue) | | `Granit.Notifications.Email.Scaleway` | Email | Scaleway TEM (sovereign EU) | | `Granit.Notifications.Email.SendGrid` | Email | SendGrid (Twilio) | | `Granit.Notifications.Twilio` | SMS + WhatsApp | Twilio Messaging | | `Granit.Notifications.Email.Smtp` | Email | Any SMTP server | | `Granit.Notifications.WebPush` | Web Push | VAPID (standard) | | `Granit.Notifications.SignalR` | Real-time | SignalR (WebSocket) | *** ## Feature comparison [Section titled “Feature comparison”](#feature-comparison) ### Identity and Authentication [Section titled “Identity and Authentication”](#identity-and-authentication) | Capability | AWS (Cognito) | Azure (Entra ID) | Google Cloud | Alibaba Cloud | | --------------------- | :-----------: | :--------------: | :---------------: | :-----------: | | JWT validation | ✓ | ✓ | ✓ | planned | | Claims transformation | ✓ | ✓ | ✓ | planned | | User CRUD | ✓ | ✓ | ✓ | planned | | Role management | ✓ | ✓ | ✓ (custom claims) | planned | | User cache sync | ✓ | ✓ | ✓ | planned | | Health check | ✓ | ✓ | ✓ | planned | ### Vault and Encryption [Section titled “Vault and Encryption”](#vault-and-encryption) | Capability | AWS KMS | Azure Key Vault | Google Cloud KMS | Alibaba Cloud KMS | | ---------------------- | :-----: | :-------------: | :--------------: | :---------------: | | Encrypt / Decrypt | ✓ | ✓ | ✓ | planned | | Dynamic DB credentials | ✓ | ✗ | ✓ | planned | | Secret storage | ✓ | ✓ | ✓ | planned | | Key rotation | ✓ | ✓ | ✓ | planned | | Tenant-isolated keys | ✓ | ✓ | ✓ | planned | | Health check | ✓ | ✓ | ✓ | planned | ### Blob Storage [Section titled “Blob Storage”](#blob-storage) | Capability | AWS S3 | Azure Blob | Google Cloud Storage | Alibaba Cloud OSS | | ------------------ | :----: | :--------: | :------------------: | :---------------: | | Presigned upload | ✓ | ✓ | ✓ | planned | | Presigned download | ✓ | ✓ | ✓ | planned | | Tenant isolation | ✓ | ✓ | ✓ | planned | | Health check | ✓ | ✓ | ✓ | planned | ### Notifications [Section titled “Notifications”](#notifications) | Channel | AWS | Azure | Google Cloud | Alibaba Cloud | | ----------- | :-----: | :-----: | :----------: | :-----------: | | Email | ✓ (SES) | ✓ (ACS) | ✗ | planned | | SMS | ✓ (SNS) | ✓ (ACS) | ✗ | planned | | Mobile Push | ✓ (SNS) | ✓ (ANH) | ✓ (FCM) | planned | # Configuration Keys > Complete reference of all appsettings sections and Options classes across Granit packages This page lists every configuration section and `Options` class provided by Granit packages. Developers should use it as the canonical lookup when adding or overriding configuration keys. ## Convention [Section titled “Convention”](#convention) All Granit configuration sections live under a **flat key** that matches the module name. Most classes declare a `public const string SectionName` used in the binding call. Nested sections use the colon separator (e.g. `Cache:Redis`, `Notifications:Email`). Typical binding in a module: ```csharp services.Configure( configuration.GetSection(ObservabilityOptions.SectionName)); ``` ## Configuration layering [Section titled “Configuration layering”](#configuration-layering) The .NET configuration system merges sources in order of precedence (last wins): 1. `appsettings.json` — base defaults, committed to the repository. 2. `appsettings.{ASPNETCORE_ENVIRONMENT}.json` — environment overrides (Development, Staging, Production). 3. Environment variables — deployed via Kubernetes ConfigMaps or container orchestrators. 4. HashiCorp Vault (via `Granit.Vault`) — secrets and dynamic credentials injected at startup. ### Overriding via environment variables [Section titled “Overriding via environment variables”](#overriding-via-environment-variables) .NET maps configuration keys to environment variables using the **double underscore** (`__`) separator in place of `:`: | appsettings path | Environment variable | | ------------------------------ | -------------------------------- | | `Cache:Redis:Configuration` | `Cache__Redis__Configuration` | | `Observability:OtlpEndpoint` | `Observability__OtlpEndpoint` | | `Notifications:Email:Provider` | `Notifications__Email__Provider` | | `BlobStorage:ServiceUrl` | `BlobStorage__ServiceUrl` | For arrays, use the index as a key segment: ```bash Wolverine__RetryDelays__0=00:00:05 Wolverine__RetryDelays__1=00:00:30 ``` *** ## Core and utilities [Section titled “Core and utilities”](#core-and-utilities) ### Timing — `ClockOptions` [Section titled “Timing — ClockOptions”](#timing--clockoptions) | Key | Type | Default | Description | | ----------------- | --------- | ----------------------------- | --------------------------------------------------------------------------------- | | **Section** | — | *(none — configured in code)* | | | **Package** | — | `Granit.Timing` | | | `DefaultTimezone` | `string?` | `null` | IANA timezone (e.g. `Europe/Brussels`). `null` = no conversion, dates remain UTC. | ### GUIDs — `GuidGeneratorOptions` [Section titled “GUIDs — GuidGeneratorOptions”](#guids--guidgeneratoroptions) | Key | Type | Default | Description | | --------------------------- | --------------------- | ---------------------- | ----------------------------------------------------- | | **Section** | — | *(configured in code)* | | | **Package** | — | `Granit.Guids` | | | `DefaultSequentialGuidType` | `SequentialGuidType?` | `null` | `null` = `SequentialAsString` (PostgreSQL-optimized). | *** ## Security and authentication [Section titled “Security and authentication”](#security-and-authentication) ### Authentication (JWT Bearer) — `JwtBearerAuthOptions` [Section titled “Authentication (JWT Bearer) — JwtBearerAuthOptions”](#authentication-jwt-bearer--jwtbearerauthoptions) | Key | Type | Default | Description | | ---------------------------------------- | ---------- | --------------------------------- | ------------------------------------------- | | **Section** | — | `Authentication` | | | **Package** | — | `Granit.Authentication.JwtBearer` | | | `Authority` | `string` | `""` | OIDC authority URL. | | `Audience` | `string` | `""` | Expected JWT audience. | | `RequireHttpsMetadata` | `bool` | `true` | Require HTTPS for OIDC metadata. | | `NameClaimType` | `string` | `"sub"` | Claim mapped to `IIdentity.Name`. | | `BackChannelLogout:Enabled` | `bool` | `false` | Enable OIDC back-channel logout. | | `BackChannelLogout:EndpointPath` | `string` | `"/auth/back-channel-logout"` | Logout endpoint route. | | `BackChannelLogout:SessionRevocationTtl` | `TimeSpan` | `01:00:00` | How long revoked session IDs stay in cache. | ### Keycloak authentication — `KeycloakOptions` [Section titled “Keycloak authentication — KeycloakOptions”](#keycloak-authentication--keycloakoptions) | Key | Type | Default | Description | | ---------------------- | --------- | -------------------------------- | ------------------------------------------ | | **Section** | — | `Keycloak` | | | **Package** | — | `Granit.Authentication.Keycloak` | | | `Authority` | `string` | `""` | OIDC authority URL. | | `ClientId` | `string` | `""` | Keycloak client ID. | | `ClientSecret` | `string` | `""` | Client secret (load from Vault). | | `RequireHttpsMetadata` | `bool` | `true` | Require HTTPS for OIDC metadata. | | `Audience` | `string?` | `null` | Expected audience. Defaults to `ClientId`. | | `AdminRole` | `string` | `"admin"` | Keycloak realm role for admins. | | `RoleClaimsSource` | `string` | `"realm_access"` | `"realm_access"` or `"resource_access"`. | ### Entra ID authentication — `EntraIdOptions` [Section titled “Entra ID authentication — EntraIdOptions”](#entra-id-authentication--entraidoptions) | Key | Type | Default | Description | | ---------------------- | -------- | -------------------------------------- | -------------------------------- | | **Section** | — | `EntraId` | | | **Package** | — | `Granit.Authentication.EntraId` | | | `Instance` | `string` | `"https://login.microsoftonline.com/"` | Azure AD instance URL. | | `TenantId` | `string` | `""` | Azure AD tenant ID. | | `ClientId` | `string` | `""` | App registration client ID. | | `RequireHttpsMetadata` | `bool` | `true` | Require HTTPS for OIDC metadata. | | `AdminRole` | `string` | `"admin"` | Admin App Role name. | ### Cognito authentication — `CognitoOptions` [Section titled “Cognito authentication — CognitoOptions”](#cognito-authentication--cognitooptions) | Key | Type | Default | Description | | ------------ | -------- | ------------------------------- | ------------------------------ | | **Section** | — | `Cognito` | | | **Package** | — | `Granit.Authentication.Cognito` | | | `UserPoolId` | `string` | `""` | Cognito User Pool ID. | | `ClientId` | `string` | `""` | Cognito app client ID. | | `Region` | `string` | `""` | AWS region (e.g. `eu-west-1`). | ### API key authentication — `ApiKeyOptions` [Section titled “API key authentication — ApiKeyOptions”](#api-key-authentication--apikeyoptions) | Key | Type | Default | Description | | --------------- | ---------- | ---------------------------------------- | ---------------------------------------------------------- | | **Section** | — | *(configured via authentication scheme)* | | | **Package** | — | `Granit.Authentication.ApiKeys` | | | `CacheDuration` | `TimeSpan` | `00:05:00` | Cache TTL for API key lookups. `TimeSpan.Zero` to disable. | | `TrackLastUsed` | `bool` | `true` | Update `LastUsedAt` on each request. | ### API key endpoints — `ApiKeysEndpointsOptions` [Section titled “API key endpoints — ApiKeysEndpointsOptions”](#api-key-endpoints--apikeysendpointsoptions) | Key | Type | Default | Description | | --------------------- | ---------- | ----------------------------------------- | -------------------------------------- | | **Section** | — | `ApiKeysEndpoints` | | | **Package** | — | `Granit.Authentication.ApiKeys.Endpoints` | | | `RoutePrefix` | `string` | `"api-keys"` | Route prefix. | | `TagName` | `string` | `"API Keys"` | OpenAPI tag. | | `RequiredRole` | `string` | `"granit-apikeys-admin"` | Fallback authorization role. | | `AllowedEnvironments` | `string[]` | `["live","test","dev"]` | Allowed environments for key creation. | ### Authorization — `GranitAuthorizationOptions` [Section titled “Authorization — GranitAuthorizationOptions”](#authorization--granitauthorizationoptions) | Key | Type | Default | Description | | --------------- | ---------- | ---------------------- | ------------------------------------------- | | **Section** | — | `Authorization` | | | **Package** | — | `Granit.Authorization` | | | `AdminRoles` | `string[]` | `["admin"]` | Roles that bypass all permission checks. | | `CacheDuration` | `TimeSpan` | `00:05:00` | Permission check cache TTL. | | `AlwaysAllow` | `bool` | `false` | Skip permission checks entirely (dev only). | ### Authorization endpoints — `AuthorizationEndpointsOptions` [Section titled “Authorization endpoints — AuthorizationEndpointsOptions”](#authorization-endpoints--authorizationendpointsoptions) | Key | Type | Default | Description | | ------------- | -------- | -------------------------------- | ------------- | | **Section** | — | `AuthorizationEndpoints` | | | **Package** | — | `Granit.Authorization.Endpoints` | | | `RoutePrefix` | `string` | `"auth"` | Route prefix. | | `TagName` | `string` | `"Authorization"` | OpenAPI tag. | ### Vault — `HashiCorpVaultOptions` [Section titled “Vault — HashiCorpVaultOptions”](#vault--hashicorpvaultoptions) | Key | Type | Default | Description | | ----------------------- | --------- | ---------------------------- | -------------------------------- | | **Section** | — | `Vault` | | | **Package** | — | `Granit.Vault.HashiCorp` | | | `Address` | `string` | `""` | Vault server URL. | | `AuthMethod` | `string` | `"Kubernetes"` | `"Kubernetes"` or `"Token"`. | | `Token` | `string?` | `null` | Vault token (dev only). | | `KubernetesRole` | `string` | `"my-backend"` | K8s auth role. | | `KubernetesTokenPath` | `string` | `/var/run/secrets/.../token` | Path to K8s service account JWT. | | `DatabaseMountPoint` | `string` | `"database"` | Database engine mount point. | | `DatabaseRoleName` | `string` | `"readwrite"` | Dynamic credential role. | | `TransitMountPoint` | `string` | `"transit"` | Transit engine mount point. | | `LeaseRenewalThreshold` | `double` | `0.75` | Lease renewal at 75% of TTL. | ### Encryption — `StringEncryptionOptions` [Section titled “Encryption — StringEncryptionOptions”](#encryption--stringencryptionoptions) | Key | Type | Default | Description | | -------------- | -------- | --------------------- | -------------------------------------------- | | **Section** | — | `Encryption` | | | **Package** | — | `Granit.Encryption` | | | `PassPhrase` | `string` | `""` | AES key derivation passphrase (from Vault). | | `KeySize` | `int` | `256` | AES key size in bits. | | `ProviderName` | `string` | `"Aes"` | `"Aes"`, `"Vault"`, or `"AzureKeyVault"`. | | `VaultKeyName` | `string` | `"string-encryption"` | Transit key name (when provider is `Vault`). | ### Azure Key Vault — `AzureKeyVaultOptions` [Section titled “Azure Key Vault — AzureKeyVaultOptions”](#azure-key-vault--azurekeyvaultoptions) | Key | Type | Default | Description | | ------------------------------ | --------- | --------------------- | -------------------------------------------------------------------- | | **Section** | — | `Vault:Azure` | | | **Package** | — | `Granit.Vault.Azure` | | | `VaultUri` | `string` | `""` | Azure Key Vault URI (e.g. `https://my-vault.vault.azure.net/`). | | `EncryptionKeyName` | `string` | `"string-encryption"` | Key name for encrypt/decrypt operations. | | `EncryptionAlgorithm` | `string` | `"RSA-OAEP-256"` | Algorithm: `RSA-OAEP-256`, `RSA-OAEP`, or `RSA1_5`. | | `DatabaseSecretName` | `string?` | `null` | Secret name for DB credentials. Omit to disable credential rotation. | | `RotationCheckIntervalMinutes` | `int` | `5` | Secret version polling interval (minutes). | | `TimeoutSeconds` | `int` | `30` | Azure SDK operation timeout. | ### Privacy — `GranitPrivacyOptions` [Section titled “Privacy — GranitPrivacyOptions”](#privacy--granitprivacyoptions) | Key | Type | Default | Description | | ---------------------- | ----- | ---------------- | --------------------------------- | | **Section** | — | `Privacy` | | | **Package** | — | `Granit.Privacy` | | | `ExportTimeoutMinutes` | `int` | `5` | GDPR export saga timeout. | | `ExportMaxSizeMb` | `int` | `100` | Maximum export archive size (MB). | *** ## Identity [Section titled “Identity”](#identity) ### Keycloak Admin API — `KeycloakAdminOptions` [Section titled “Keycloak Admin API — KeycloakAdminOptions”](#keycloak-admin-api--keycloakadminoptions) | Key | Type | Default | Description | | ----------------------------------- | --------- | -------------------------- | ------------------------------------------------- | | **Section** | — | `KeycloakAdmin` | | | **Package** | — | `Granit.Identity.Keycloak` | | | `BaseUrl` | `string` | `""` | Keycloak server base URL. | | `Realm` | `string` | `""` | Realm name. | | `ClientId` | `string` | `""` | Service account client ID. | | `ClientSecret` | `string` | `""` | Service account client secret (from Vault). | | `UseTokenExchangeForDeviceActivity` | `bool` | `false` | Use token exchange for device-level session info. | | `TimeoutSeconds` | `int` | `30` | HTTP request timeout. | | `DirectAccessClientId` | `string?` | `null` | Public client for credential verification. | ### Entra ID Admin API — `EntraIdAdminOptions` [Section titled “Entra ID Admin API — EntraIdAdminOptions”](#entra-id-admin-api--entraidadminoptions) | Key | Type | Default | Description | | -------------------------- | --------- | ------------------------------- | ----------------------------------------------- | | **Section** | — | `EntraIdAdmin` | | | **Package** | — | `Granit.Identity.EntraId` | | | `TenantId` | `string` | `""` | Azure AD tenant ID. | | `ClientId` | `string` | `""` | Service principal client ID. | | `ClientSecret` | `string` | `""` | Service principal secret (from Vault). | | `ServicePrincipalObjectId` | `string` | `""` | Enterprise app Object ID for App Role ops. | | `DefaultDomain` | `string?` | `null` | Domain for `userPrincipalName` construction. | | `TimeoutSeconds` | `int` | `30` | Microsoft Graph request timeout. | | `GraphBaseUrl` | `string` | `"https://graph.microsoft.com"` | Graph API base URL. | | `RopcClientId` | `string?` | `null` | Public client for ROPC credential verification. | ### Cognito Admin API — `CognitoAdminOptions` [Section titled “Cognito Admin API — CognitoAdminOptions”](#cognito-admin-api--cognitoadminoptions) | Key | Type | Default | Description | | ----------------- | --------- | ------------------------- | --------------------------------------------------------------------- | | **Section** | — | `CognitoAdmin` | | | **Package** | — | `Granit.Identity.Cognito` | | | `UserPoolId` | `string` | `""` | Cognito User Pool ID. | | `Region` | `string` | `""` | AWS region (e.g. `eu-west-1`). | | `AccessKeyId` | `string?` | `null` | AWS access key (optional — uses default credential chain if omitted). | | `SecretAccessKey` | `string?` | `null` | AWS secret key (from Vault). | | `TimeoutSeconds` | `int` | `30` | HTTP request timeout. | ### User cache — `UserCacheOptions` [Section titled “User cache — UserCacheOptions”](#user-cache--usercacheoptions) | Key | Type | Default | Description | | -------------------------- | ---------- | ------------------------------------- | --------------------------------------------- | | **Section** | — | `IdentityUserCache` | | | **Package** | — | `Granit.Identity.EntityFrameworkCore` | | | `StalenessThreshold` | `TimeSpan` | `1.00:00:00` | Duration before a cached user is stale. | | `EnableLoginTimeSync` | `bool` | `true` | Auto-sync user from JWT on each request. | | `IncrementalSyncBatchSize` | `int` | `50` | Max stale entries per incremental sync batch. | ### Identity endpoints — `IdentityEndpointsOptions` [Section titled “Identity endpoints — IdentityEndpointsOptions”](#identity-endpoints--identityendpointsoptions) | Key | Type | Default | Description | | -------------- | -------- | --------------------------- | ---------------------------- | | **Section** | — | `IdentityEndpoints` | | | **Package** | — | `Granit.Identity.Endpoints` | | | `RoutePrefix` | `string` | `"identity/users"` | Route prefix. | | `TagName` | `string` | `"Identity User Cache"` | OpenAPI tag. | | `RequiredRole` | `string` | `"granit-identity-admin"` | Fallback authorization role. | ### Identity webhook — `IdentityWebhookOptions` [Section titled “Identity webhook — IdentityWebhookOptions”](#identity-webhook--identitywebhookoptions) | Key | Type | Default | Description | | --------------------- | -------- | --------------------------- | ------------------------------------------------- | | **Section** | — | `IdentityWebhook` | | | **Package** | — | `Granit.Identity.Endpoints` | | | `Secret` | `string` | `""` | HMAC-SHA256 shared secret for webhook validation. | | `SignatureHeaderName` | `string` | `"X-Webhook-Signature"` | HTTP header carrying the signature. | *** ## Data and persistence [Section titled “Data and persistence”](#data-and-persistence) ### Caching — `CachingOptions` [Section titled “Caching — CachingOptions”](#caching--cachingoptions) | Key | Type | Default | Description | | ---------------------------------------- | ----------- | ---------------- | ------------------------------------------------ | | **Section** | — | `Cache` | | | **Package** | — | `Granit.Caching` | | | `KeyPrefix` | `string` | `"dd"` | Prefix for all cache keys. | | `DefaultAbsoluteExpirationRelativeToNow` | `TimeSpan?` | `01:00:00` | Default absolute expiration. | | `DefaultSlidingExpiration` | `TimeSpan?` | `00:20:00` | Default sliding expiration. | | `EncryptValues` | `bool` | `false` | Enable AES-256 encryption for all cached values. | ### Cache encryption — `CacheEncryptionOptions` [Section titled “Cache encryption — CacheEncryptionOptions”](#cache-encryption--cacheencryptionoptions) | Key | Type | Default | Description | | ----------- | --------- | ------------------ | --------------------------------------------------------- | | **Section** | — | `Cache:Encryption` | | | **Package** | — | `Granit.Caching` | | | `Key` | `string?` | `null` | AES-256 key (base64, 32 bytes). From Vault in production. | ### Hybrid cache — `HybridCachingOptions` [Section titled “Hybrid cache — HybridCachingOptions”](#hybrid-cache--hybridcachingoptions) | Key | Type | Default | Description | | ---------------------- | ---------- | ----------------------- | -------------------------------------------------------- | | **Section** | — | `Cache:Hybrid` | | | **Package** | — | `Granit.Caching.Hybrid` | | | `LocalCacheExpiration` | `TimeSpan` | `00:00:30` | L1 (in-memory) expiration per pod. Max recommended: 60s. | ### Redis cache — `RedisCachingOptions` [Section titled “Redis cache — RedisCachingOptions”](#redis-cache--rediscachingoptions) | Key | Type | Default | Description | | --------------- | -------- | ----------------------------------- | -------------------------------------------------- | | **Section** | — | `Cache:Redis` | | | **Package** | — | `Granit.Caching.StackExchangeRedis` | | | `IsEnabled` | `bool` | `true` | Enable Redis provider (`false` = Memory fallback). | | `Configuration` | `string` | `"localhost:6379"` | StackExchange.Redis connection string. | | `InstanceName` | `string` | `"dd:"` | Redis key prefix for app isolation. | ### Multi-tenancy — `MultiTenancyOptions` [Section titled “Multi-tenancy — MultiTenancyOptions”](#multi-tenancy--multitenancyoptions) | Key | Type | Default | Description | | -------------------- | -------- | --------------------- | ------------------------------------ | | **Section** | — | `MultiTenancy` | | | **Package** | — | `Granit.MultiTenancy` | | | `IsEnabled` | `bool` | `true` | Enable tenant resolution middleware. | | `TenantIdClaimType` | `string` | `"tenant_id"` | JWT claim for tenant ID. | | `TenantIdHeaderName` | `string` | `"X-Tenant-Id"` | HTTP header for tenant ID. | ### Tenant isolation — `TenantIsolationOptions` [Section titled “Tenant isolation — TenantIsolationOptions”](#tenant-isolation--tenantisolationoptions) | Key | Type | Default | Description | | ----------- | ------------------------- | -------------------- | ------------------------------------------------------------ | | **Section** | — | `TenantIsolation` | | | **Package** | — | `Granit.Persistence` | | | `Strategy` | `TenantIsolationStrategy` | `SharedDatabase` | `SharedDatabase`, `TenantPerSchema`, or `TenantPerDatabase`. | ### Tenant schema — `TenantSchemaOptions` [Section titled “Tenant schema — TenantSchemaOptions”](#tenant-schema--tenantschemaoptions) | Key | Type | Default | Description | | ------------------ | ------------------------------ | -------------------- | -------------------------------------- | | **Section** | — | `TenantSchema` | | | **Package** | — | `Granit.Persistence` | | | `NamingConvention` | `TenantSchemaNamingConvention` | `TenantId` | `TenantId`, `TenantName`, or `Custom`. | | `Prefix` | `string` | `"tenant_"` | Schema name prefix. | ### Data migrations — `MigrationStartupOptions` [Section titled “Data migrations — MigrationStartupOptions”](#data-migrations--migrationstartupoptions) | Key | Type | Default | Description | | ----------------------- | ---------- | ------------------------------- | -------------------------------------------- | | **Section** | — | `GranitMigrations` | | | **Package** | — | `Granit.Persistence.Migrations` | | | `DefaultBatchSize` | `int` | `500` | Rows per batch when resuming pending cycles. | | `BatchExecutionTimeout` | `TimeSpan` | `00:05:00` | Timeout per migration batch. | ### Settings — `SettingsOptions` [Section titled “Settings — SettingsOptions”](#settings--settingsoptions) | Key | Type | Default | Description | | ----------------- | ---------- | ----------------- | ------------------------------ | | **Section** | — | `Settings` | | | **Package** | — | `Granit.Settings` | | | `CacheExpiration` | `TimeSpan` | `00:30:00` | Settings cache entry lifetime. | ### Settings endpoints — `SettingsEndpointsOptions` [Section titled “Settings endpoints — SettingsEndpointsOptions”](#settings-endpoints--settingsendpointsoptions) | Key | Type | Default | Description | | ------------------- | -------- | --------------------------- | ----------------------------- | | **Section** | — | *(configured in code)* | | | **Package** | — | `Granit.Settings.Endpoints` | | | `UserRoutePrefix` | `string` | `"settings/user"` | User-scoped settings route. | | `GlobalRoutePrefix` | `string` | `"settings/global"` | Global settings route. | | `TenantRoutePrefix` | `string` | `"settings/tenant"` | Tenant-scoped settings route. | | `TagName` | `string` | `"Settings"` | OpenAPI tag. | ### Reference data — `ReferenceDataOptions` [Section titled “Reference data — ReferenceDataOptions”](#reference-data--referencedataoptions) | Key | Type | Default | Description | | ----------------- | ---------- | ---------------------- | --------------------------------------- | | **Section** | — | `ReferenceData` | | | **Package** | — | `Granit.ReferenceData` | | | `CacheTimeToLive` | `TimeSpan` | `01:00:00` | In-memory cache TTL for reference data. | ### Reference data endpoints — `ReferenceDataEndpointsOptions` [Section titled “Reference data endpoints — ReferenceDataEndpointsOptions”](#reference-data-endpoints--referencedataendpointsoptions) | Key | Type | Default | Description | | ----------------- | --------- | -------------------------------- | --------------------------------------------- | | **Section** | — | *(configured in code)* | | | **Package** | — | `Granit.ReferenceData.Endpoints` | | | `RoutePrefix` | `string` | `"reference-data"` | Route prefix. | | `TagName` | `string` | `"Reference Data"` | OpenAPI tag. | | `AdminPolicyName` | `string?` | `"ReferenceData.Admin"` | Admin authorization policy. `null` = no auth. | | `RequiredRole` | `string` | `"granit-reference-data-admin"` | Fallback admin role. | ### Querying — `QueryingOptions` [Section titled “Querying — QueryingOptions”](#querying--queryingoptions) | Key | Type | Default | Description | | ----------------- | ----- | ----------------- | ---------------------------------------- | | **Section** | — | `Querying` | | | **Package** | — | `Granit.Querying` | | | `DefaultPageSize` | `int` | `20` | Default page size for paginated queries. | | `MaxPageSize` | `int` | `100` | Maximum allowed page size. | *** ## API and web [Section titled “API and web”](#api-and-web) ### API versioning — `GranitApiVersioningOptions` [Section titled “API versioning — GranitApiVersioningOptions”](#api-versioning--granitapiversioningoptions) | Key | Type | Default | Description | | --------------------- | ------ | ---------------------- | ----------------------------------------- | | **Section** | — | `ApiVersioning` | | | **Package** | — | `Granit.ApiVersioning` | | | `DefaultMajorVersion` | `int` | `1` | Default API version when client omits it. | | `ReportApiVersions` | `bool` | `true` | Include version headers in responses. | ### API documentation — `ApiDocumentationOptions` [Section titled “API documentation — ApiDocumentationOptions”](#api-documentation--apidocumentationoptions) | Key | Type | Default | Description | | ------------------------- | ---------- | ------------------------- | ------------------------------------------- | | **Section** | — | `ApiDocumentation` | | | **Package** | — | `Granit.ApiDocumentation` | | | `MajorVersions` | `int[]` | `[1]` | API versions to generate OpenAPI docs for. | | `Title` | `string` | `"API"` | API title in Scalar UI. | | `Description` | `string?` | `null` | Markdown description in OpenAPI info. | | `ContactEmail` | `string?` | `null` | Contact email in OpenAPI info. | | `LogoUrl` | `string?` | `null` | Logo image URL for Scalar UI. | | `FaviconUrl` | `string?` | `null` | Favicon URL for Scalar page. | | `EnableInProduction` | `bool` | `false` | Expose docs in Production. | | `EnableTenantHeader` | `bool` | `false` | Document tenant header on endpoints. | | `TenantHeaderName` | `string` | `"X-Tenant-Id"` | Tenant header name. | | `AuthorizationPolicy` | `string?` | `null` | Policy for doc endpoints. `""` = anonymous. | | `OAuth2:AuthorizationUrl` | `string?` | `null` | OAuth2 authorization endpoint. | | `OAuth2:TokenUrl` | `string?` | `null` | OAuth2 token endpoint. | | `OAuth2:ClientId` | `string?` | `null` | Public OAuth2 client ID (PKCE). | | `OAuth2:EnablePkce` | `bool` | `true` | Enable PKCE with S256. | | `OAuth2:Scopes` | `string[]` | `["openid"]` | OAuth2 scopes to request. | ### Exception handling — `ExceptionHandlingOptions` [Section titled “Exception handling — ExceptionHandlingOptions”](#exception-handling--exceptionhandlingoptions) | Key | Type | Default | Description | | ---------------------------- | ------ | -------------------------- | --------------------------------------------------------------------------------------- | | **Section** | — | *(configured in code)* | | | **Package** | — | `Granit.ExceptionHandling` | | | `ExposeInternalErrorDetails` | `bool` | `false` | Show internal error messages in ProblemDetails. Never `true` in production (ISO 27001). | ### CORS — `GranitCorsOptions` [Section titled “CORS — GranitCorsOptions”](#cors--granitcorsoptions) | Key | Type | Default | Description | | ------------------ | ---------- | ------------- | ----------------------------------------------------------------------------- | | **Section** | — | `Cors` | | | **Package** | — | `Granit.Cors` | | | `AllowedOrigins` | `string[]` | `[]` | Allowed CORS origins. Wildcard `*` forbidden outside Development (ISO 27001). | | `AllowCredentials` | `bool` | `false` | Include `Access-Control-Allow-Credentials`. | ### Cookies — `GranitCookiesOptions` [Section titled “Cookies — GranitCookiesOptions”](#cookies--granitcookiesoptions) | Key | Type | Default | Description | | ---------------------- | ------- | ---------------- | ----------------------------------------------- | | **Section** | — | `Cookies` | | | **Package** | — | `Granit.Cookies` | | | `ThrowOnUnregistered` | `bool` | `true` | Fail-fast on unregistered cookies. | | `DefaultRetentionDays` | `int` | `365` | Default cookie retention period. | | `ThirdPartyServices` | `array` | `[]` | Third-party services for CMP setup (see below). | Each entry in `ThirdPartyServices`: | Key | Type | Description | | ---------------- | ---------------- | ---------------------------------------- | | `Name` | `string` | Service identifier (e.g. `"matomo"`). | | `Category` | `CookieCategory` | GDPR consent category. | | `CookiePatterns` | `string[]` | Regex patterns matching service cookies. | ### Cookies — Klaro CMP — `KlaroOptions` [Section titled “Cookies — Klaro CMP — KlaroOptions”](#cookies--klaro-cmp--klarooptions) | Key | Type | Default | Description | | ------------ | -------- | ---------------------- | -------------------------- | | **Section** | — | `Klaro` | | | **Package** | — | `Granit.Cookies.Klaro` | | | `CookieName` | `string` | `"klaro"` | Klaro consent cookie name. | ### Cookie consent endpoints — `CookieConsentEndpointsOptions` [Section titled “Cookie consent endpoints — CookieConsentEndpointsOptions”](#cookie-consent-endpoints--cookieconsentendpointsoptions) | Key | Type | Default | Description | | ------------- | -------- | -------------------------- | ------------- | | **Section** | — | *(configured in code)* | | | **Package** | — | `Granit.Cookies.Endpoints` | | | `RoutePrefix` | `string` | `"cookies"` | Route prefix. | | `TagName` | `string` | `"Cookies"` | OpenAPI tag. | ### Idempotency — `IdempotencyOptions` [Section titled “Idempotency — IdempotencyOptions”](#idempotency--idempotencyoptions) | Key | Type | Default | Description | | ------------------ | ---------- | -------------------- | -------------------------------------- | | **Section** | — | `Idempotency` | | | **Package** | — | `Granit.Idempotency` | | | `HeaderName` | `string` | `"Idempotency-Key"` | HTTP header name. | | `KeyPrefix` | `string` | `"idp"` | Redis key prefix. | | `CompletedTtl` | `TimeSpan` | `1.00:00:00` | TTL for completed entries. | | `InProgressTtl` | `TimeSpan` | `00:00:30` | TTL for in-progress lock. | | `ExecutionTimeout` | `TimeSpan` | `00:00:25` | Max downstream handler execution time. | | `MaxBodySizeBytes` | `int` | `1048576` | Max request body size to hash (1 MiB). | ### Rate limiting — `GranitRateLimitingOptions` [Section titled “Rate limiting — GranitRateLimitingOptions”](#rate-limiting--granitratelimitingoptions) | Key | Type | Default | Description | | ------------------------------- | ----------------------------- | --------------------- | -------------------------------------------- | | **Section** | — | `RateLimiting` | | | **Package** | — | `Granit.RateLimiting` | | | `Enabled` | `bool` | `true` | Enable rate limiting. | | `KeyPrefix` | `string` | `"rl"` | Redis key prefix for counters. | | `FallbackOnCounterStoreFailure` | `CounterStoreFailureBehavior` | `Allow` | Behavior when Redis is unavailable. | | `BypassRoles` | `string[]` | `[]` | Roles exempt from rate limiting. | | `UseFeatureBasedQuotas` | `bool` | `false` | Use `Granit.Features` for plan-based quotas. | | `Policies` | `Dictionary` | `{}` | Named rate limit policies (see below). | Each entry in `Policies`: | Key | Type | Default | Description | | --------------------- | -------------------- | --------------- | ------------------------------------------------- | | `Algorithm` | `RateLimitAlgorithm` | `SlidingWindow` | `SlidingWindow`, `FixedWindow`, or `TokenBucket`. | | `PermitLimit` | `int` | `1000` | Max permits per window. | | `Window` | `TimeSpan` | `00:01:00` | Time window for sliding/fixed algorithms. | | `SegmentsPerWindow` | `int` | `6` | Segments per sliding window (1—60). | | `TokenLimit` | `int` | `50` | Max tokens for `TokenBucket`. | | `TokensPerPeriod` | `int` | `10` | Tokens added per replenishment. | | `ReplenishmentPeriod` | `TimeSpan` | `00:00:10` | Interval between replenishments. | | `FeatureName` | `string?` | `null` | Feature name override for quota resolution. | ### Bulkhead isolation — `GranitBulkheadOptions` [Section titled “Bulkhead isolation — GranitBulkheadOptions”](#bulkhead-isolation--granitbulkheadoptions) | Key | Type | Default | Description | | ----------------------- | ------------ | ----------------- | ----------------------------------------- | | **Section** | — | `Bulkhead` | | | **Package** | — | `Granit.Bulkhead` | | | `Enabled` | `bool` | `true` | Enable bulkhead isolation. | | `BypassRoles` | `string[]` | `[]` | Roles exempt from bulkhead checks. | | `UseFeatureBasedQuotas` | `bool` | `false` | Use `Granit.Features` for dynamic limits. | | `IdleTimeout` | `TimeSpan` | `00:30:00` | TTL for idle limiters before eviction. | | `CleanupInterval` | `TimeSpan` | `00:05:00` | Interval between cleanup sweeps. | | `Policies` | `Dictionary` | `{}` | Named bulkhead policies (see below). | Each entry in `Policies`: | Key | Type | Default | Description | | -------------- | ---------- | ---------- | ------------------------------------------------ | | `PermitLimit` | `int` | `10` | Max concurrent operations per tenant (1—10,000). | | `QueueLimit` | `int` | `0` | Max queued operations. `0` = reject immediately. | | `QueueTimeout` | `TimeSpan` | `00:00:30` | Max time in queue before rejection. | | `FeatureName` | `string?` | `null` | Feature name override for dynamic resolution. | *** ## Messaging and events [Section titled “Messaging and events”](#messaging-and-events) ### Wolverine (core) — `WolverineMessagingOptions` [Section titled “Wolverine (core) — WolverineMessagingOptions”](#wolverine-core--wolverinemessagingoptions) | Key | Type | Default | Description | | ------------------ | ------------ | -------------------------------- | --------------------------------------- | | **Section** | — | `Wolverine` | | | **Package** | — | `Granit.Wolverine` | | | `RetryDelays` | `TimeSpan[]` | `[00:00:05, 00:00:30, 00:05:00]` | Cooldown delays between retry attempts. | | `MaxRetryAttempts` | `int` | `3` | Maximum retry attempts. | ### Wolverine PostgreSQL — `WolverinePostgresqlOptions` [Section titled “Wolverine PostgreSQL — WolverinePostgresqlOptions”](#wolverine-postgresql--wolverinepostgresqloptions) | Key | Type | Default | Description | | --------------------------- | --------------------------- | ----------------------------- | ------------------------------------------------- | | **Section** | — | `WolverinePostgresql` | | | **Package** | — | `Granit.Wolverine.Postgresql` | | | `TransportConnectionString` | `string` | `""` | PostgreSQL connection string for outbox tables. | | `TransactionMode` | `TransactionMiddlewareMode` | `Eager` | `Eager` (ISO 27001-recommended) or `Lightweight`. | ### Wolverine SQL Server — `WolverineSqlServerOptions` [Section titled “Wolverine SQL Server — WolverineSqlServerOptions”](#wolverine-sql-server--wolverinesqlserveroptions) | Key | Type | Default | Description | | --------------------------- | --------------------------- | ---------------------------- | ------------------------------------------------- | | **Section** | — | `WolverineSqlServer` | | | **Package** | — | `Granit.Wolverine.SqlServer` | | | `TransportConnectionString` | `string` | `""` | SQL Server connection string for outbox tables. | | `TransactionMode` | `TransactionMiddlewareMode` | `Eager` | `Eager` (ISO 27001-recommended) or `Lightweight`. | ### Webhooks — `WebhooksOptions` [Section titled “Webhooks — WebhooksOptions”](#webhooks--webhooksoptions) | Key | Type | Default | Description | | ----------------------- | ------ | ----------------- | ---------------------------------------------------- | | **Section** | — | `Webhooks` | | | **Package** | — | `Granit.Webhooks` | | | `HttpTimeoutSeconds` | `int` | `10` | HTTP delivery timeout (5—120). | | `MaxParallelDeliveries` | `int` | `20` | Parallel deliveries on the local queue (1—100). | | `StorePayload` | `bool` | `false` | Persist delivery payloads (GDPR: validate with DPO). | ### Notifications (engine) — `NotificationsOptions` [Section titled “Notifications (engine) — NotificationsOptions”](#notifications-engine--notificationsoptions) | Key | Type | Default | Description | | ----------------------- | ----- | ---------------------- | ------------------------------- | | **Section** | — | `Notifications` | | | **Package** | — | `Granit.Notifications` | | | `MaxParallelDeliveries` | `int` | `8` | Max parallel delivery messages. | ### Notification endpoints — `NotificationEndpointsOptions` [Section titled “Notification endpoints — NotificationEndpointsOptions”](#notification-endpoints--notificationendpointsoptions) | Key | Type | Default | Description | | ------------- | -------- | -------------------------------- | ------------- | | **Section** | — | *(configured in code)* | | | **Package** | — | `Granit.Notifications.Endpoints` | | | `RoutePrefix` | `string` | `"notifications"` | Route prefix. | | `TagName` | `string` | `"Notifications"` | OpenAPI tag. | ### Email channel — `EmailChannelOptions` [Section titled “Email channel — EmailChannelOptions”](#email-channel--emailchanneloptions) | Key | Type | Default | Description | | --------------- | -------- | ---------------------------- | --------------------------------------------------------------------------------------------------------- | | **Section** | — | `Notifications:Email` | | | **Package** | — | `Granit.Notifications.Email` | | | `Provider` | `string` | `"Smtp"` | Keyed service provider (`"Smtp"`, `"Brevo"`, `"AzureCommunicationServices"`, `"Scaleway"`, `"SendGrid"`). | | `SenderAddress` | `string` | `""` | Default sender email. | | `SenderName` | `string` | `""` | Default sender display name. | ### SMTP — `SmtpOptions` [Section titled “SMTP — SmtpOptions”](#smtp--smtpoptions) | Key | Type | Default | Description | | ---------------- | --------- | --------------------------------- | --------------------------- | | **Section** | — | `Notifications:Smtp` | | | **Package** | — | `Granit.Notifications.Email.Smtp` | | | `Host` | `string` | `"localhost"` | SMTP server hostname. | | `Port` | `int` | `587` | SMTP server port. | | `UseSsl` | `bool` | `true` | Use SSL/TLS. | | `Username` | `string?` | `null` | SMTP username. | | `Password` | `string?` | `null` | SMTP password (from Vault). | | `TimeoutSeconds` | `int` | `30` | Connection/send timeout. | ### Brevo — `BrevoOptions` [Section titled “Brevo — BrevoOptions”](#brevo--brevooptions) | Key | Type | Default | Description | | -------------------- | -------- | ---------------------------- | --------------------------- | | **Section** | — | `Notifications:Brevo` | | | **Package** | — | `Granit.Notifications.Brevo` | | | `ApiKey` | `string` | `""` | Brevo API key (from Vault). | | `DefaultSenderEmail` | `string` | `""` | Default sender email. | | `DefaultSenderName` | `string` | `""` | Default sender name. | | `DefaultSmsSenderId` | `string` | `""` | Default SMS sender ID. | | `BaseUrl` | `string` | `"https://api.brevo.com/v3"` | Brevo API base URL. | | `TimeoutSeconds` | `int` | `30` | HTTP request timeout. | ### Scaleway TEM — `ScalewayEmailOptions` [Section titled “Scaleway TEM — ScalewayEmailOptions”](#scaleway-tem--scalewayemailoptions) | Key | Type | Default | Description | | -------------------- | -------- | ------------------------------------- | -------------------------------------------------------- | | **Section** | — | `Notifications:Email:Scaleway` | | | **Package** | — | `Granit.Notifications.Email.Scaleway` | | | `SecretKey` | `string` | `""` | Scaleway API secret key (from Vault). | | `ProjectId` | `string` | `""` | Scaleway project ID. | | `DefaultSenderEmail` | `string` | `""` | Default sender email (must be verified in Scaleway TEM). | | `DefaultSenderName` | `string` | `""` | Default sender display name. | | `Region` | `string` | `"fr-par"` | Scaleway region. | | `BaseUrl` | `string` | `"https://api.scaleway.com"` | Scaleway API base URL. | | `TimeoutSeconds` | `int` | `30` | HTTP request timeout. | ### SendGrid — `SendGridEmailOptions` [Section titled “SendGrid — SendGridEmailOptions”](#sendgrid--sendgridemailoptions) | Key | Type | Default | Description | | -------------------- | -------- | ------------------------------------- | ---------------------------------------------------- | | **Section** | — | `Notifications:Email:SendGrid` | | | **Package** | — | `Granit.Notifications.Email.SendGrid` | | | `ApiKey` | `string` | `""` | SendGrid API key (from Vault). | | `DefaultSenderEmail` | `string` | `""` | Default sender email (must be verified in SendGrid). | | `DefaultSenderName` | `string` | `""` | Default sender display name. | | `SandboxMode` | `bool` | `false` | Enable SendGrid sandbox mode (no actual delivery). | | `TimeoutSeconds` | `int` | `30` | HTTP request timeout. | ### Twilio — `TwilioOptions` [Section titled “Twilio — TwilioOptions”](#twilio--twiliooptions) | Key | Type | Default | Description | | --------------------- | --------- | ----------------------------- | ------------------------------------------------------------------- | | **Section** | — | `Notifications:Twilio` | | | **Package** | — | `Granit.Notifications.Twilio` | | | `AccountSid` | `string` | `""` | Twilio Account SID. | | `AuthToken` | `string` | `""` | Twilio Auth Token (from Vault). | | `DefaultSmsFrom` | `string` | `""` | Default SMS sender number (E.164 format) or Messaging Service SID. | | `DefaultWhatsAppFrom` | `string` | `""` | Default WhatsApp sender (e.g. `whatsapp:+14155238886`). | | `MessagingServiceSid` | `string?` | `null` | Twilio Messaging Service SID (overrides `DefaultSmsFrom` when set). | | `TimeoutSeconds` | `int` | `30` | HTTP request timeout. | ### SMS channel — `SmsChannelOptions` [Section titled “SMS channel — SmsChannelOptions”](#sms-channel--smschanneloptions) | Key | Type | Default | Description | | ----------- | --------- | -------------------------- | ------------------------------------------------------------------------------------------- | | **Section** | — | `Notifications:Sms` | | | **Package** | — | `Granit.Notifications.Sms` | | | `Provider` | `string` | `""` | Keyed service provider (`"Brevo"`, `"AzureCommunicationServices"`, `"AwsSns"`, `"Twilio"`). | | `SenderId` | `string?` | `null` | Default sender ID. | ### WhatsApp channel — `WhatsAppChannelOptions` [Section titled “WhatsApp channel — WhatsAppChannelOptions”](#whatsapp-channel--whatsappchanneloptions) | Key | Type | Default | Description | | ----------- | -------- | ------------------------------- | ----------------------------------------------- | | **Section** | — | `Notifications:WhatsApp` | | | **Package** | — | `Granit.Notifications.WhatsApp` | | | `Provider` | `string` | `""` | Keyed service provider (`"Brevo"`, `"Twilio"`). | ### Web Push (VAPID) — `PushChannelOptions` [Section titled “Web Push (VAPID) — PushChannelOptions”](#web-push-vapid--pushchanneloptions) | Key | Type | Default | Description | | ----------------- | -------- | ------------------------------ | ------------------------------------------ | | **Section** | — | `Notifications:Push` | | | **Package** | — | `Granit.Notifications.WebPush` | | | `VapidSubject` | `string` | `""` | VAPID subject (`mailto:` or `https:` URL). | | `VapidPublicKey` | `string` | `""` | VAPID public key (base64 URL-safe). | | `VapidPrivateKey` | `string` | `""` | VAPID private key (from Vault). | ### Mobile Push — `MobilePushChannelOptions` [Section titled “Mobile Push — MobilePushChannelOptions”](#mobile-push--mobilepushchanneloptions) | Key | Type | Default | Description | | ----------- | -------- | --------------------------------- | ----------------------- | | **Section** | — | `Notifications:MobilePush` | | | **Package** | — | `Granit.Notifications.MobilePush` | | | `Provider` | `string` | `"GoogleFcm"` | Keyed service provider. | ### Firebase Cloud Messaging — `GoogleFcmOptions` [Section titled “Firebase Cloud Messaging — GoogleFcmOptions”](#firebase-cloud-messaging--googlefcmoptions) | Key | Type | Default | Description | | -------------------- | -------- | ------------------------------------------- | -------------------------------------- | | **Section** | — | `Notifications:MobilePush:GoogleFcm` | | | **Package** | — | `Granit.Notifications.MobilePush.GoogleFcm` | | | `ProjectId` | `string` | `""` | Firebase project ID. | | `ServiceAccountJson` | `string` | `""` | Service account JSON key (from Vault). | | `BaseAddress` | `string` | `"https://fcm.googleapis.com/"` | FCM API base address. | | `TimeoutSeconds` | `int` | `30` | Request timeout. | ### ACS Email — `AcsEmailOptions` [Section titled “ACS Email — AcsEmailOptions”](#acs-email--acsemailoptions) | Key | Type | Default | Description | | ------------------ | --------- | ------------------------------------------------------- | ---------------------------------------------------------- | | **Section** | — | `AzureCommunicationServices:Email` | | | **Package** | — | `Granit.Notifications.Email.AzureCommunicationServices` | | | `ConnectionString` | `string?` | `null` | ACS connection string. Mutually exclusive with `Endpoint`. | | `Endpoint` | `string?` | `null` | ACS endpoint URI (uses `DefaultAzureCredential`). | | `SenderAddress` | `string` | `""` | Sender email address (must be verified in ACS). | | `TimeoutSeconds` | `int` | `120` | Send operation timeout (ACS emails can take time). | ### ACS SMS — `AcsSmsOptions` [Section titled “ACS SMS — AcsSmsOptions”](#acs-sms--acssmsoptions) | Key | Type | Default | Description | | ------------------ | --------- | ----------------------------------------------------- | ---------------------------------------------------------- | | **Section** | — | `AzureCommunicationServices:Sms` | | | **Package** | — | `Granit.Notifications.Sms.AzureCommunicationServices` | | | `ConnectionString` | `string?` | `null` | ACS connection string. Mutually exclusive with `Endpoint`. | | `Endpoint` | `string?` | `null` | ACS endpoint URI (uses `DefaultAzureCredential`). | | `FromPhoneNumber` | `string` | `""` | Sender phone number in E.164 format (must start with `+`). | | `TimeoutSeconds` | `int` | `30` | Send operation timeout. | ### Azure Notification Hubs — `AzureNotificationHubsOptions` [Section titled “Azure Notification Hubs — AzureNotificationHubsOptions”](#azure-notification-hubs--azurenotificationhubsoptions) | Key | Type | Default | Description | | ------------------ | -------- | ------------------------------------------------------- | ----------------------------------- | | **Section** | — | `Notifications:AzureNotificationHubs` | | | **Package** | — | `Granit.Notifications.MobilePush.AzureNotificationHubs` | | | `ConnectionString` | `string` | `""` | Notification Hub connection string. | | `HubName` | `string` | `""` | Notification Hub name. | | `TimeoutSeconds` | `int` | `30` | Send operation timeout. | ### SignalR channel — `SignalRChannelOptions` [Section titled “SignalR channel — SignalRChannelOptions”](#signalr-channel--signalrchanneloptions) | Key | Type | Default | Description | | ----------------------- | --------- | ------------------------------ | --------------------------------------------------- | | **Section** | — | `Notifications:SignalR` | | | **Package** | — | `Granit.Notifications.SignalR` | | | `RedisConnectionString` | `string?` | `null` | Redis connection for SignalR backplane (multi-pod). | ### SSE channel — `SseChannelOptions` [Section titled “SSE channel — SseChannelOptions”](#sse-channel--ssechanneloptions) | Key | Type | Default | Description | | -------------------------- | ----- | -------------------------- | ------------------------------ | | **Section** | — | `Notifications:Sse` | | | **Package** | — | `Granit.Notifications.Sse` | | | `HeartbeatIntervalSeconds` | `int` | `30` | Keep-alive heartbeat interval. | ### Zulip channel — `ZulipChannelOptions` [Section titled “Zulip channel — ZulipChannelOptions”](#zulip-channel--zulipchanneloptions) | Key | Type | Default | Description | | --------------- | -------- | ---------------------------- | --------------------- | | **Section** | — | `Notifications:Zulip` | | | **Package** | — | `Granit.Notifications.Zulip` | | | `DefaultStream` | `string` | `"alerts"` | Default Zulip stream. | | `DefaultTopic` | `string` | `"system"` | Default Zulip topic. | ### Zulip bot — `ZulipBotOptions` [Section titled “Zulip bot — ZulipBotOptions”](#zulip-bot--zulipbotoptions) | Key | Type | Default | Description | | ---------------- | -------- | ---------------------------- | ------------------------- | | **Section** | — | `Notifications:Zulip:Bot` | | | **Package** | — | `Granit.Notifications.Zulip` | | | `BaseUrl` | `string` | `""` | Zulip server base URL. | | `BotEmail` | `string` | `""` | Bot email address. | | `ApiKey` | `string` | `""` | Bot API key (from Vault). | | `TimeoutSeconds` | `int` | `30` | Request timeout. | *** ## Documents and templates [Section titled “Documents and templates”](#documents-and-templates) ### Templating endpoints — `TemplatingEndpointsOptions` [Section titled “Templating endpoints — TemplatingEndpointsOptions”](#templating-endpoints--templatingendpointsoptions) | Key | Type | Default | Description | | ------------- | -------- | ----------------------------- | ------------- | | **Section** | — | *(configured in code)* | | | **Package** | — | `Granit.Templating.Endpoints` | | | `RoutePrefix` | `string` | `"templates"` | Route prefix. | | `TagName` | `string` | `"Templates"` | OpenAPI tag. | ### PDF rendering — `PdfRenderOptions` [Section titled “PDF rendering — PdfRenderOptions”](#pdf-rendering--pdfrenderoptions) | Key | Type | Default | Description | | ------------------------ | --------- | ------------------------------- | ------------------------------------------------------ | | **Section** | — | `DocumentGeneration:Pdf` | | | **Package** | — | `Granit.DocumentGeneration.Pdf` | | | `PaperFormat` | `string` | `"A4"` | Paper format (`A4`, `A5`, `Letter`). | | `Landscape` | `bool` | `false` | Landscape orientation. | | `MarginTop` | `string` | `"10mm"` | Top margin (CSS units). | | `MarginBottom` | `string` | `"10mm"` | Bottom margin. | | `MarginLeft` | `string` | `"10mm"` | Left margin. | | `MarginRight` | `string` | `"10mm"` | Right margin. | | `HeaderTemplate` | `string?` | `null` | HTML header template (PuppeteerSharp classes). | | `FooterTemplate` | `string?` | `null` | HTML footer template. | | `PrintBackground` | `bool` | `true` | Print background graphics. | | `ChromiumExecutablePath` | `string?` | `null` | Custom Chromium path. `null` = PuppeteerSharp-managed. | | `MaxConcurrentPages` | `int` | `4` | Max parallel Chromium tabs (1—32). | *** ## Data exchange (import/export) [Section titled “Data exchange (import/export)”](#data-exchange-importexport) ### Import — `ImportOptions` [Section titled “Import — ImportOptions”](#import--importoptions) | Key | Type | Default | Description | | ---------------------- | -------- | --------------------- | ----------------------------------------------------- | | **Section** | — | `DataExchange` | | | **Package** | — | `Granit.DataExchange` | | | `DefaultMaxFileSizeMb` | `int` | `50` | Max file size (MB) unless overridden per definition. | | `DefaultBatchSize` | `int` | `500` | Default import batch size. | | `FuzzyMatchThreshold` | `double` | `0.8` | Minimum fuzzy matching score for mapping suggestions. | ### Export — `ExportOptions` [Section titled “Export — ExportOptions”](#export--exportoptions) | Key | Type | Default | Description | | --------------------- | ----- | --------------------- | ------------------------------------------------------ | | **Section** | — | `DataExport` | | | **Package** | — | `Granit.DataExchange` | | | `BackgroundThreshold` | `int` | `1000` | Row count above which export runs as a background job. | ### Data exchange endpoints — `DataExchangeEndpointsOptions` [Section titled “Data exchange endpoints — DataExchangeEndpointsOptions”](#data-exchange-endpoints--dataexchangeendpointsoptions) | Key | Type | Default | Description | | -------------- | -------- | ------------------------------- | ---------------------------- | | **Section** | — | `DataExchangeEndpoints` | | | **Package** | — | `Granit.DataExchange.Endpoints` | | | `RoutePrefix` | `string` | `"data-exchange"` | Route prefix. | | `RequiredRole` | `string` | `"granit-data-exchange-admin"` | Fallback authorization role. | | `TagName` | `string` | `"Data Exchange"` | OpenAPI tag. | *** ## Workflow [Section titled “Workflow”](#workflow) ### Workflow endpoints — `WorkflowEndpointsOptions` [Section titled “Workflow endpoints — WorkflowEndpointsOptions”](#workflow-endpoints--workflowendpointsoptions) | Key | Type | Default | Description | | -------------- | -------- | --------------------------- | ---------------------------- | | **Section** | — | `WorkflowEndpoints` | | | **Package** | — | `Granit.Workflow.Endpoints` | | | `RoutePrefix` | `string` | `"workflow"` | Route prefix. | | `RequiredRole` | `string` | `"granit-workflow-admin"` | Fallback authorization role. | | `TagName` | `string` | `"Workflow"` | OpenAPI tag. | *** ## Diagnostics and observability [Section titled “Diagnostics and observability”](#diagnostics-and-observability) ### Observability — `ObservabilityOptions` [Section titled “Observability — ObservabilityOptions”](#observability--observabilityoptions) | Key | Type | Default | Description | | ------------------ | -------- | ------------------------- | ----------------------- | | **Section** | — | `Observability` | | | **Package** | — | `Granit.Observability` | | | `ServiceName` | `string` | `"unknown-service"` | OTEL service name. | | `ServiceVersion` | `string` | `"0.0.0"` | Service version. | | `OtlpEndpoint` | `string` | `"http://localhost:4317"` | OTLP gRPC endpoint. | | `ServiceNamespace` | `string` | `"my-company"` | OTEL service namespace. | | `Environment` | `string` | `"development"` | Deployment environment. | | `EnableTracing` | `bool` | `true` | Enable trace export. | | `EnableMetrics` | `bool` | `true` | Enable metrics export. | ### Diagnostics — `DiagnosticsOptions` [Section titled “Diagnostics — DiagnosticsOptions”](#diagnostics--diagnosticsoptions) | Key | Type | Default | Description | | ---------------------- | ---------- | ---------------------- | ---------------------------- | | **Section** | — | *(configured in code)* | | | **Package** | — | `Granit.Diagnostics` | | | `LivenessPath` | `string` | `"/health/live"` | Liveness probe path. | | `ReadinessPath` | `string` | `"/health/ready"` | Readiness probe path. | | `StartupPath` | `string` | `"/health/startup"` | Startup probe path. | | `DefaultCacheDuration` | `TimeSpan` | `00:00:10` | Health check cache duration. | ### Timeline endpoints — `TimelineEndpointsOptions` [Section titled “Timeline endpoints — TimelineEndpointsOptions”](#timeline-endpoints--timelineendpointsoptions) | Key | Type | Default | Description | | -------------- | -------- | --------------------------- | ---------------------------- | | **Section** | — | `TimelineEndpoints` | | | **Package** | — | `Granit.Timeline.Endpoints` | | | `RoutePrefix` | `string` | `"timeline"` | Route prefix. | | `RequiredRole` | `string` | `"granit-timeline-user"` | Fallback authorization role. | | `TagName` | `string` | `"Timeline"` | OpenAPI tag. | *** ## Storage [Section titled “Storage”](#storage) ### Blob storage (core) — `BlobStorageOptions` [Section titled “Blob storage (core) — BlobStorageOptions”](#blob-storage-core--blobstorageoptions) | Key | Type | Default | Description | | ------------------- | ---------- | -------------------- | ---------------------------- | | **Section** | — | `BlobStorage` | | | **Package** | — | `Granit.BlobStorage` | | | `UploadUrlExpiry` | `TimeSpan` | `00:15:00` | Pre-signed upload URL TTL. | | `DownloadUrlExpiry` | `TimeSpan` | `00:05:00` | Pre-signed download URL TTL. | ### Blob storage S3 — `S3BlobOptions` [Section titled “Blob storage S3 — S3BlobOptions”](#blob-storage-s3--s3bloboptions) Extends `BlobStorageOptions` with S3-specific settings. Bound from the same `BlobStorage` section. | Key | Type | Default | Description | | ----------------- | --------------------- | ----------------------- | ----------------------------------------- | | **Section** | — | `BlobStorage` | | | **Package** | — | `Granit.BlobStorage.S3` | | | `ServiceUrl` | `string` | `""` | S3-compatible endpoint URL. | | `AccessKey` | `string` | `""` | S3 access key (from Vault). | | `SecretKey` | `string` | `""` | S3 secret key (from Vault). | | `Region` | `string` | `"us-east-1"` | S3 region identifier. | | `DefaultBucket` | `string` | `""` | Default bucket name. | | `ForcePathStyle` | `bool` | `true` | Use path-style URLs (required for MinIO). | | `TenantIsolation` | `BlobTenantIsolation` | `Prefix` | `Prefix` or `BucketPerTenant`. | ### Blob storage Azure — `AzureBlobOptions` [Section titled “Blob storage Azure — AzureBlobOptions”](#blob-storage-azure--azurebloboptions) Extends `BlobStorageOptions` with Azure Blob-specific settings. Bound from the same `BlobStorage` section. | Key | Type | Default | Description | | -------------------- | --------------------- | ------------------------------ | ---------------------------------------------------------------- | | **Section** | — | `BlobStorage` | | | **Package** | — | `Granit.BlobStorage.AzureBlob` | | | `ConnectionString` | `string` | `""` | Azure Storage connection string (from Vault). | | `DefaultContainer` | `string` | `""` | Default blob container name. | | `UseManagedIdentity` | `bool` | `false` | Use Azure Managed Identity instead of connection string. | | `ServiceUri` | `string` | `""` | Storage account URI (required when `UseManagedIdentity = true`). | | `TenantIsolation` | `BlobTenantIsolation` | `Prefix` | `Prefix` or `Container` (one per tenant). | ### Blob storage FileSystem — `FileSystemBlobOptions` [Section titled “Blob storage FileSystem — FileSystemBlobOptions”](#blob-storage-filesystem--filesystembloboptions) Extends `BlobStorageOptions` with local file system settings. Bound from the same `BlobStorage` section. | Key | Type | Default | Description | | ----------- | -------- | ------------------------------- | ------------------------------------------- | | **Section** | — | `BlobStorage` | | | **Package** | — | `Granit.BlobStorage.FileSystem` | | | `BasePath` | `string` | `""` | Root directory for blob storage (required). | ### Blob storage Database — `DatabaseBlobOptions` [Section titled “Blob storage Database — DatabaseBlobOptions”](#blob-storage-database--databasebloboptions) Extends `BlobStorageOptions` with database storage settings. Bound from the same `BlobStorage` section. | Key | Type | Default | Description | | ------------------ | ------ | ----------------------------- | ------------------------------------------- | | **Section** | — | `BlobStorage` | | | **Package** | — | `Granit.BlobStorage.Database` | | | `MaxBlobSizeBytes` | `long` | `10485760` (10 MB) | Maximum blob size accepted by the provider. | ### Blob storage Proxy — `ProxyBlobOptions` [Section titled “Blob storage Proxy — ProxyBlobOptions”](#blob-storage-proxy--proxybloboptions) Configuration for the proxy endpoint provider used by FileSystem and Database providers. Bound from the `BlobStorage:Proxy` section. | Key | Type | Default | Description | | ---------------- | -------- | -------------------------- | ---------------------------------------- | | **Section** | — | `BlobStorage:Proxy` | | | **Package** | — | `Granit.BlobStorage.Proxy` | | | `BaseUrl` | `string` | `""` | Public URL of the API server (required). | | `RoutePrefix` | `string` | `"/api/blobs"` | Route prefix for proxy endpoints. | | `MaxUploadBytes` | `long` | `104857600` (100 MB) | Maximum upload size through proxy. | *** ## Scheduling and jobs [Section titled “Scheduling and jobs”](#scheduling-and-jobs) ### Background jobs — `BackgroundJobsOptions` [Section titled “Background jobs — BackgroundJobsOptions”](#background-jobs--backgroundjobsoptions) | Key | Type | Default | Description | | ------------------ | -------------- | ----------------------- | --------------------------------------------------------- | | **Section** | — | `BackgroundJobs` | | | **Package** | — | `Granit.BackgroundJobs` | | | `Mode` | `JobStoreMode` | `InMemory` | `InMemory` (dev) or `Durable` (EF Core). | | `ConnectionString` | `string` | `""` | DB connection string (required when `Mode` is `Durable`). | ### Background jobs endpoints — `BackgroundJobsEndpointsOptions` [Section titled “Background jobs endpoints — BackgroundJobsEndpointsOptions”](#background-jobs-endpoints--backgroundjobsendpointsoptions) | Key | Type | Default | Description | | -------------- | -------- | --------------------------------- | ---------------------------- | | **Section** | — | `BackgroundJobsEndpoints` | | | **Package** | — | `Granit.BackgroundJobs.Endpoints` | | | `RoutePrefix` | `string` | `"background-jobs"` | Route prefix. | | `RequiredRole` | `string` | `"granit-background-jobs-admin"` | Fallback authorization role. | | `TagName` | `string` | `"Background Jobs"` | OpenAPI tag. | *** ## Localization [Section titled “Localization”](#localization) ### Localization — `GranitLocalizationOptions` [Section titled “Localization — GranitLocalizationOptions”](#localization--granitlocalizationoptions) | Key | Type | Default | Description | | --------------------- | ------ | --------------------------------- | --------------------------------------------------------------- | | **Section** | — | *(configured in code via lambda)* | | | **Package** | — | `Granit.Localization` | | | `EnableAutoDiscovery` | `bool` | `false` | Auto-discover JSON localization resources by naming convention. | `GranitLocalizationOptions` is primarily configured through code (`services.AddGranitLocalization(options => ...)`) rather than `appsettings.json`. Properties like `Languages`, `Resources`, and `FormattingCultures` are populated programmatically. ### Localization overrides cache — `LocalizationOverridesCacheOptions` [Section titled “Localization overrides cache — LocalizationOverridesCacheOptions”](#localization-overrides-cache--localizationoverridescacheoptions) | Key | Type | Default | Description | | ----------- | ---------- | ---------------------- | ------------------------------------------- | | **Section** | — | *(configured in code)* | | | **Package** | — | `Granit.Localization` | | | `CacheTtl` | `TimeSpan` | `00:05:00` | In-memory TTL for DB override dictionaries. | ### Localization endpoints — `LocalizationEndpointsOptions` [Section titled “Localization endpoints — LocalizationEndpointsOptions”](#localization-endpoints--localizationendpointsoptions) | Key | Type | Default | Description | | ------------- | -------- | ------------------------------- | ------------- | | **Section** | — | *(configured in code)* | | | **Package** | — | `Granit.Localization.Endpoints` | | | `RoutePrefix` | `string` | `"localization"` | Route prefix. | | `TagName` | `string` | `"Localization"` | OpenAPI tag. | # Dependency Graph > Package dependency visualization and module relationship map for all 97 Granit packages This page documents the dependency graph across all 135 Granit source packages. Arrows indicate the direction of usage: `A --> B` means “A is used by B”. `Granit.Core` is the root and feeds the entire tree. Conventions used throughout this page: * **Diagram arrows** flow from foundation to consumers (`Core → Security → Wolverine`). Tables still list dependencies from the consumer’s perspective (“Depends on”). * Transitive dependencies on `Granit.Core` are omitted when a package already depends on another module that depends on Core. * The systematic pattern `*.Endpoints → Authorization` is omitted from the overview diagram (documented in the coupling rules section). * `*.EntityFrameworkCore` packages are leaf nodes unless stated otherwise. ## High-level overview [Section titled “High-level overview”](#high-level-overview) Each node represents a functional domain with the package count in parentheses. ``` flowchart TD CORE["Core (1)"] subgraph Foundation UTILS["Utilities (13)"] SEC["Security (12)"] CACHE["Caching (3)"] IDENT["Identity (5)"] end subgraph Infrastructure PERS["Persistence (3)"] WOL["Wolverine (3)"] end subgraph Functional LOC["Localization (4)"] WEB["Web, API, Webhooks (9)"] CONFIG["Configuration (8)"] STORAGE["Storage (9)"] end subgraph Business TMPL["Templating (8)"] QRY["Querying (3)"] DX["DataExchange (6)"] WF["Workflow (4)"] NOTIF["Notifications (15)"] TL["Timeline (4)"] JOBS["Background Jobs (4)"] end ANLZ["Analyzers (2)"] CORE --> UTILS CORE --> SEC CORE --> CACHE CORE --> LOC CACHE --> SEC SEC --> PERS UTILS --> PERS UTILS --> STORAGE SEC --> WOL PERS --> WOL PERS --> LOC PERS --> QRY PERS --> WF CORE --> IDENT PERS --> IDENT SEC --> WEB CACHE --> WEB CACHE --> CONFIG LOC --> CONFIG PERS --> CONFIG UTILS --> TMPL QRY --> NOTIF WOL --> DX WOL --> JOBS SEC --> TL SEC --> JOBS QRY --> DX WF --> TMPL NOTIF --> WF NOTIF --> TL IDENT --> WF ``` ### Domain composition [Section titled “Domain composition”](#domain-composition) | Domain | Packages | | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Utilities | Timing, Guids, Diagnostics, Validation, Validation.Europe, ExceptionHandling, Observability, MultiTenancy, Privacy, Cors, Bulkhead, RateLimiting, Querying | | Identity | Identity, Identity.Keycloak, Identity.EntraId, Identity.Cognito, Identity.EntityFrameworkCore, Identity.Endpoints | | Security | Security, Encryption, Vault, Vault.HashiCorp, Vault.Azure, Vault.Aws, Auth.JwtBearer, Auth.Keycloak, Auth.EntraId, Auth.Cognito, Auth.ApiKeys (3), Authorization, Authorization.EF, Authorization.Endpoints | | Configuration | Settings (3), Features (2), ReferenceData (3) | | Web, API, and Webhooks | ApiVersioning, ApiDocumentation, Cookies, Cookies.Klaro, Cookies.Endpoints, Idempotency, Webhooks (3) | | Storage | BlobStorage (7), Imaging (2) | | Background Jobs | BackgroundJobs (4) | | Localization | Localization, Localization.EntityFrameworkCore, Localization.Endpoints, Localization.SourceGenerator | | Templating | Templating, Templating.Scriban, Templating.EF, Templating.Endpoints, Templating.Workflow, DocumentGeneration, DocumentGeneration.Pdf, DocumentGeneration.Excel | | Notifications | Notifications, Notifications.EF, Notifications.Endpoints, Notifications.Wolverine, Email, Email.Smtp, Email.AzureCommunicationServices, Sms, Sms.AzureCommunicationServices, WhatsApp, WebPush, SignalR, Sse, Zulip, Brevo, MobilePush, MobilePush.GoogleFcm, MobilePush.AzureNotificationHubs | | Workflow | Workflow, Workflow\.EF, Workflow\.Endpoints, Workflow\.Notifications | | Timeline | Timeline, Timeline.EF, Timeline.Endpoints, Timeline.Notifications | | DataExchange | DataExchange, DataExchange.Csv, DataExchange.Excel, DataExchange.EF, DataExchange.Endpoints, DataExchange.Wolverine | ## Core layer dependencies [Section titled “Core layer dependencies”](#core-layer-dependencies) The backbone of the framework: security, distributed cache, and data persistence. ``` flowchart TD CORE["Core"] subgraph Primitives TIMING["Timing"] GUIDS["Guids"] EXC["ExceptionHandling"] ENCR["Encryption"] end subgraph Security SEC["Security"] JWT["Auth.JwtBearer"] KC["Auth.Keycloak"] ENTRA["Auth.EntraId"] APIKEYS["Auth.ApiKeys"] APIKEYS_EP["Auth.ApiKeys.Endpoints"] APIKEYS_EF["Auth.ApiKeys.EF"] end subgraph Authorization AUTHZ["Authorization"] AUTHZ_EF["Authorization.EF"] AUTHZ_EP["Authorization.Endpoints"] end subgraph Caching CACHE["Caching"] CACHE_REDIS["Caching.Redis"] CACHE_HYB["Caching.Hybrid"] end subgraph Persistence PERS["Persistence"] PERS_MIG["Persistence.Migrations"] PERS_MIG_WOL["Persistence.Migrations.Wolverine"] end subgraph Wolverine WOL["Wolverine"] WOL_PG["Wolverine.Postgresql"] WOL_SQL["Wolverine.SqlServer"] end CORE --> Primitives CORE --> SEC CORE --> CACHE ENCR --> VAULT["Vault"] VAULT --> VAULT_HC["Vault.HashiCorp"] VAULT --> VAULT_AZ["Vault.Azure"] VAULT --> VAULT_AW["Vault.Aws"] TIMING --> GUIDS SEC --> JWT JWT --> KC JWT --> ENTRA SEC --> APIKEYS APIKEYS --> APIKEYS_EP APIKEYS --> APIKEYS_EF SEC --> AUTHZ CACHE --> AUTHZ AUTHZ --> AUTHZ_EF AUTHZ --> AUTHZ_EP CACHE --> CACHE_REDIS CACHE_REDIS --> CACHE_HYB GUIDS --> PERS SEC --> PERS EXC --> PERS PERS --> PERS_MIG PERS_MIG --> PERS_MIG_WOL SEC --> WOL WOL --> WOL_PG WOL --> WOL_SQL WOL --> PERS_MIG_WOL PERS --> WOL_PG PERS --> WOL_SQL style KC fill:#e8f5e9,stroke:#43a047,color:#1b5e20 style ENTRA fill:#e8f5e9,stroke:#43a047,color:#1b5e20 style VAULT_HC fill:#e8f5e9,stroke:#43a047,color:#1b5e20 style VAULT_AZ fill:#e8f5e9,stroke:#43a047,color:#1b5e20 style VAULT_AW fill:#e8f5e9,stroke:#43a047,color:#1b5e20 style CACHE_REDIS fill:#e8f5e9,stroke:#43a047,color:#1b5e20 style WOL_PG fill:#e8f5e9,stroke:#43a047,color:#1b5e20 style WOL_SQL fill:#e8f5e9,stroke:#43a047,color:#1b5e20 ``` ### Utilities (flat dependencies) [Section titled “Utilities (flat dependencies)”](#utilities-flat-dependencies) | Package | Depends on | | -------------------------- | --------------------------------------------------- | | `Granit.Timing` | `Core` | | `Granit.Security` | `Core` | | `Granit.ExceptionHandling` | `Core` | | `Granit.Observability` | `Core` | | `Granit.MultiTenancy` | `Core` | | `Granit.Privacy` | `Core` | | `Granit.Cors` | `Core` | | `Granit.Guids` | `Timing` | | `Granit.Diagnostics` | `Timing` | | `Granit.Validation` | `ExceptionHandling`, `Localization` | | `Granit.Validation.Europe` | `Validation`, `Localization` | | `Granit.Bulkhead` | `Core`, `ExceptionHandling`, `Features`, `Security` | | `Granit.RateLimiting` | `Core`, `ExceptionHandling`, `Features`, `Security` | ### Identity [Section titled “Identity”](#identity) | Package | Depends on | | ------------------------------------- | ------------------------------------- | | `Granit.Identity` | `Querying` | | `Granit.Identity.Keycloak` | `Identity` | | `Granit.Identity.EntraId` | `Identity`, `Timing` | | `Granit.Identity.EntityFrameworkCore` | `Identity`, `Persistence`, `Security` | | `Granit.Identity.Endpoints` | `Identity`, `Authorization` | ### Localization [Section titled “Localization”](#localization) | Package | Depends on | | ----------------------------------------- | ------------------------------- | | `Granit.Localization` | `Core` | | `Granit.Localization.EntityFrameworkCore` | `Localization`, `Persistence` | | `Granit.Localization.Endpoints` | `Localization`, `Authorization` | | `Granit.Localization.SourceGenerator` | none (source generator) | ### Configuration (Settings, Features, ReferenceData) [Section titled “Configuration (Settings, Features, ReferenceData)”](#configuration-settings-features-referencedata) | Package | Depends on | | ------------------------------------------ | --------------------------------------------------- | | `Granit.Settings` | `Caching`, `Encryption`, `Security` | | `Granit.Settings.EntityFrameworkCore` | `Settings`, `Persistence` | | `Granit.Settings.Endpoints` | `Settings`, `Authorization`, `Timing`, `Validation` | | `Granit.Features` | `Caching`, `Localization` | | `Granit.Features.EntityFrameworkCore` | `Features`, `Persistence` | | `Granit.ReferenceData` | `Querying` | | `Granit.ReferenceData.Endpoints` | `ReferenceData` | | `Granit.ReferenceData.EntityFrameworkCore` | `ReferenceData`, `Persistence` | ### Web, API, and Webhooks [Section titled “Web, API, and Webhooks”](#web-api-and-webhooks) | Package | Depends on | | ------------------------------------- | --------------------------- | | `Granit.ApiVersioning` | `Core` | | `Granit.ApiDocumentation` | `ApiVersioning`, `Security` | | `Granit.Cookies` | `Timing` | | `Granit.Cookies.Klaro` | `Cookies` | | `Granit.Cookies.Endpoints` | `Cookies`, `Core` | | `Granit.Idempotency` | `Caching`, `Security` | | `Granit.Webhooks` | `Timing`, `Wolverine` | | `Granit.Webhooks.EntityFrameworkCore` | `Webhooks`, `Persistence` | | `Granit.Webhooks.Wolverine` | `Webhooks`, `Wolverine` | ### Storage and Imaging [Section titled “Storage and Imaging”](#storage-and-imaging) | Package | Depends on | | ---------------------------------------- | ---------------------------- | | `Granit.BlobStorage` | `Guids` | | `Granit.BlobStorage.S3` | `BlobStorage` | | `Granit.BlobStorage.AzureBlob` | `BlobStorage` | | `Granit.BlobStorage.FileSystem` | `BlobStorage` | | `Granit.BlobStorage.Database` | `BlobStorage`, `Persistence` | | `Granit.BlobStorage.Proxy` | `BlobStorage` | | `Granit.BlobStorage.EntityFrameworkCore` | `BlobStorage`, `Persistence` | | `Granit.Imaging` | `Core` | | `Granit.Imaging.MagickNet` | `Imaging` | ## Messaging and notification layer [Section titled “Messaging and notification layer”](#messaging-and-notification-layer) ### Notifications [Section titled “Notifications”](#notifications) Fan-out multi-channel engine with Brevo as a unified aggregator across Email, SMS, and WhatsApp. ``` flowchart LR NOTIF["Notifications"] --> NOTIF_EP["Notifications.Endpoints"] NOTIF --> NOTIF_EF["Notifications.EF"] NOTIF --> NOTIF_WOL["Notifications.Wolverine"] NOTIF --> NOTIF_EMAIL["Notifications.Email"] NOTIF_EMAIL --> NOTIF_SMTP["Notifications.Email.Smtp"] NOTIF --> NOTIF_SMS["Notifications.Sms"] NOTIF --> NOTIF_WA["Notifications.WhatsApp"] NOTIF --> NOTIF_PUSH["Notifications.WebPush"] NOTIF --> NOTIF_SR["Notifications.SignalR"] NOTIF --> NOTIF_SSE["Notifications.Sse"] NOTIF --> NOTIF_ZULIP["Notifications.Zulip"] NOTIF --> NOTIF_MP["Notifications.MobilePush"] NOTIF_MP --> NOTIF_FCM["Notifications.MobilePush.GoogleFcm"] NOTIF_EMAIL --> NOTIF_BREVO["Notifications.Brevo"] NOTIF_SMS --> NOTIF_BREVO NOTIF_WA --> NOTIF_BREVO NOTIF_EMAIL --> NOTIF_ACS_EMAIL["Notifications.Email.AzureCommunicationServices"] NOTIF_SMS --> NOTIF_ACS_SMS["Notifications.Sms.AzureCommunicationServices"] NOTIF_MP --> NOTIF_ANH["Notifications.MobilePush.AzureNotificationHubs"] style NOTIF_EMAIL fill:#e3f2fd,stroke:#1976d2,color:#0d47a1 style NOTIF_SMS fill:#e3f2fd,stroke:#1976d2,color:#0d47a1 style NOTIF_WA fill:#e3f2fd,stroke:#1976d2,color:#0d47a1 style NOTIF_MP fill:#e3f2fd,stroke:#1976d2,color:#0d47a1 style NOTIF_SMTP fill:#e8f5e9,stroke:#43a047,color:#1b5e20 style NOTIF_BREVO fill:#e8f5e9,stroke:#43a047,color:#1b5e20 style NOTIF_ACS_EMAIL fill:#e8f5e9,stroke:#43a047,color:#1b5e20 style NOTIF_ACS_SMS fill:#e8f5e9,stroke:#43a047,color:#1b5e20 style NOTIF_ANH fill:#e8f5e9,stroke:#43a047,color:#1b5e20 style NOTIF_SR fill:#e8f5e9,stroke:#43a047,color:#1b5e20 style NOTIF_SSE fill:#e8f5e9,stroke:#43a047,color:#1b5e20 style NOTIF_ZULIP fill:#e8f5e9,stroke:#43a047,color:#1b5e20 style NOTIF_PUSH fill:#e8f5e9,stroke:#43a047,color:#1b5e20 style NOTIF_FCM fill:#e8f5e9,stroke:#43a047,color:#1b5e20 ``` ### Templating and Document Generation [Section titled “Templating and Document Generation”](#templating-and-document-generation) Template engine (Scriban) with document rendering pipeline (HTML-to-PDF, Excel). ``` flowchart LR TMPL["Templating"] --> SCRIBAN["Templating.Scriban"] TMPL --> TMPL_EF["Templating.EF"] TMPL --> TMPL_EP["Templating.Endpoints"] TMPL --> TMPL_WF["Templating.Workflow"] TMPL --> DOCGEN["DocumentGeneration"] DOCGEN --> DOCGEN_PDF["DocumentGeneration.Pdf"] TMPL --> DOCGEN_EXCEL["DocumentGeneration.Excel"] style SCRIBAN fill:#e8f5e9,stroke:#43a047,color:#1b5e20 style DOCGEN_PDF fill:#e8f5e9,stroke:#43a047,color:#1b5e20 style DOCGEN_EXCEL fill:#e8f5e9,stroke:#43a047,color:#1b5e20 ``` | Package | Depends on | | --------------------------------------- | ----------------------------- | | `Granit.Templating` | `Timing` | | `Granit.Templating.Scriban` | `Templating` | | `Granit.Templating.EntityFrameworkCore` | `Templating` | | `Granit.Templating.Endpoints` | `Templating`, `Authorization` | | `Granit.Templating.Workflow` | `Templating`, `Workflow` | | `Granit.DocumentGeneration` | `Templating` | | `Granit.DocumentGeneration.Pdf` | `DocumentGeneration` | | `Granit.DocumentGeneration.Excel` | `Templating` | ### Workflow and Timeline [Section titled “Workflow and Timeline”](#workflow-and-timeline) These two domains share cross-module dependencies with Notifications and Identity. | Package | Depends on | | ------------------------------------- | -------------------------------------------------------- | | `Granit.Workflow` | `Timing` | | `Granit.Workflow.EntityFrameworkCore` | `Workflow`, `Persistence` | | `Granit.Workflow.Endpoints` | `Workflow`, `Authorization` | | `Granit.Workflow.Notifications` | `Workflow`, `Authorization`, `Identity`, `Notifications` | | `Granit.Timeline` | `Guids`, `Security` | | `Granit.Timeline.EntityFrameworkCore` | `Timeline`, `Persistence` | | `Granit.Timeline.Endpoints` | `Timeline`, `Authorization` | | `Granit.Timeline.Notifications` | `Timeline`, `Notifications` | ### Background Jobs [Section titled “Background Jobs”](#background-jobs) | Package | Depends on | | ------------------------------------------- | --------------------------------- | | `Granit.BackgroundJobs` | `Timing`, `Wolverine` | | `Granit.BackgroundJobs.EntityFrameworkCore` | `BackgroundJobs` | | `Granit.BackgroundJobs.Endpoints` | `BackgroundJobs`, `Authorization` | | `Granit.BackgroundJobs.Wolverine` | `BackgroundJobs`, `Wolverine` | ### Querying [Section titled “Querying”](#querying) | Package | Depends on | | ------------------------------------- | --------------------------- | | `Granit.Querying` | `Core` | | `Granit.Querying.Endpoints` | `Querying`, `Authorization` | | `Granit.Querying.EntityFrameworkCore` | `Querying`, `Persistence` | ### DataExchange [Section titled “DataExchange”](#dataexchange) Import/export pipeline with format adapters (CSV, Excel) and async processing via Wolverine. ``` flowchart LR DX["DataExchange"] --> DX_CSV["DataExchange.Csv"] DX --> DX_EXCEL["DataExchange.Excel"] DX --> DX_EF["DataExchange.EF"] DX --> DX_EP["DataExchange.Endpoints"] DX --> DX_WOL["DataExchange.Wolverine"] style DX_CSV fill:#e8f5e9,stroke:#43a047,color:#1b5e20 style DX_EXCEL fill:#e8f5e9,stroke:#43a047,color:#1b5e20 ``` | Package | Depends on | | ----------------------------------------- | ---------------------------------- | | `Granit.DataExchange` | `Querying`, `Timing`, `Validation` | | `Granit.DataExchange.Csv` | `DataExchange` | | `Granit.DataExchange.Excel` | `DataExchange` | | `Granit.DataExchange.EntityFrameworkCore` | `DataExchange`, `Persistence` | | `Granit.DataExchange.Endpoints` | `DataExchange`, `Authorization` | | `Granit.DataExchange.Wolverine` | `DataExchange`, `Wolverine` | ### Analyzers [Section titled “Analyzers”](#analyzers) | Package | Depends on | | ---------------------------- | ---------------------- | | `Granit.Analyzers` | none (Roslyn analyzer) | | `Granit.Analyzers.CodeFixes` | `Analyzers` | ## Soft dependencies [Section titled “Soft dependencies”](#soft-dependencies) Several core interfaces live in `Granit.Core` rather than in their dedicated module. This allows any package to consume them without taking a hard reference to the implementing module. The implementing module registers the real service; without it, a null-object default is used. | Interface | Declared in | Implemented by | Default behavior | | ---------------- | --------------------------- | --------------------- | ------------------------------------------- | | `ICurrentTenant` | `Granit.Core.MultiTenancy` | `Granit.MultiTenancy` | `NullTenantContext` (`IsAvailable = false`) | | `IDataFilter` | `Granit.Core.DataFiltering` | `Granit.Persistence` | No-op (all data visible) | Modules that access `ICurrentTenant` use `using Granit.Core.MultiTenancy;` and check `IsAvailable` before reading `Id`. They do NOT declare `[DependsOn(typeof(GranitMultiTenancyModule))]` or add a `ProjectReference` to `Granit.MultiTenancy`. The only exception is modules that must enforce strict tenant isolation (e.g., BlobStorage for GDPR compliance). ## Bundle composition [Section titled “Bundle composition”](#bundle-composition) Five meta-packages provide curated sets of modules for common application profiles. Bundles contain no code — they are `ProjectReference`-only `.csproj` files. ### Granit.Bundle.Essentials [Section titled “Granit.Bundle.Essentials”](#granitbundleessentials) Minimal API foundation. | Included package | | -------------------------- | | `Granit.Core` | | `Granit.Timing` | | `Granit.Guids` | | `Granit.Security` | | `Granit.Validation` | | `Granit.Persistence` | | `Granit.Observability` | | `Granit.ExceptionHandling` | | `Granit.Diagnostics` | ### Granit.Bundle.Api [Section titled “Granit.Bundle.Api”](#granitbundleapi) Complete REST API. Includes everything in `Bundle.Essentials` plus: | Included package | | ----------------------------------------- | | `Granit.ApiVersioning` | | `Granit.ApiDocumentation` | | `Granit.Cors` | | `Granit.Idempotency` | | `Granit.Localization` | | `Granit.Localization.EntityFrameworkCore` | | `Granit.Caching` | ### Granit.Bundle.Documents [Section titled “Granit.Bundle.Documents”](#granitbundledocuments) Templating and document generation pipeline. | Included package | | --------------------------------------- | | `Granit.Templating` | | `Granit.Templating.Scriban` | | `Granit.Templating.EntityFrameworkCore` | | `Granit.DocumentGeneration` | | `Granit.DocumentGeneration.Pdf` | | `Granit.DocumentGeneration.Excel` | ### Granit.Bundle.Notifications [Section titled “Granit.Bundle.Notifications”](#granitbundlenotifications) Multi-channel notification engine with default channels. | Included package | | ------------------------------------------ | | `Granit.Notifications` | | `Granit.Notifications.EntityFrameworkCore` | | `Granit.Notifications.Endpoints` | | `Granit.Notifications.Email` | | `Granit.Notifications.Email.Smtp` | | `Granit.Notifications.SignalR` | ### Granit.Bundle.SaaS [Section titled “Granit.Bundle.SaaS”](#granitbundlesaas) Multi-tenant SaaS extensions. | Included package | | ------------------------------------- | | `Granit.MultiTenancy` | | `Granit.Features` | | `Granit.Features.EntityFrameworkCore` | | `Granit.RateLimiting` | | `Granit.Bulkhead` | ## Dependency rules [Section titled “Dependency rules”](#dependency-rules) These invariants are enforced by `Granit.ArchitectureTests` and apply to every package in the repository. 1. **Core depends on nothing.** It is the root of the entire graph. 2. **Zero circular references.** The graph is a strict DAG (directed acyclic graph). The build will fail if a cycle is introduced. 3. **One project = one NuGet package.** The namespace matches the project name. No assembly contains types from another package’s namespace. 4. **Functional packages never reference `*.EntityFrameworkCore` packages.** Abstractions live in the base package; EF Core implementations live in the `*.EntityFrameworkCore` companion. This keeps the base package ORM-agnostic. 5. **`*.Endpoints` packages depend on `Granit.Authorization`.** Every endpoint package uses `RequireAuthorization()` or policy-based authorization. This is a systematic pattern omitted from the diagrams for readability. 6. **Wolverine is the sole message bus.** All asynchronous processing flows through Wolverine: Notifications, Webhooks, BackgroundJobs, DataExchange.Wolverine, Persistence.Migrations.Wolverine. 7. **`Persistence.Migrations` is decoupled from Wolverine.** Batch dispatch is abstracted via `IMigrationBatchDispatcher` (Channel-based by default, Wolverine optional via `Granit.Persistence.Migrations.Wolverine`). 8. **Multi-tenancy is a soft dependency.** Modules use `ICurrentTenant` from `Granit.Core.MultiTenancy` without referencing `Granit.MultiTenancy`. Hard dependency is reserved for strict tenant isolation (BlobStorage, GDPR). ## Graph properties [Section titled “Graph properties”](#graph-properties) * **135 source packages**, zero circular dependencies * **Maximum depth**: 5 levels (e.g., Core to Security to Wolverine to Notifications to Email to Smtp, or Core to Timing to Notifications to MobilePush to MobilePush.GoogleFcm) * **Leaf packages**: `*.EntityFrameworkCore` and `*.S3` packages are almost always leaves * **Root packages with no dependencies**: `Granit.Core`, `Granit.Analyzers`, `Granit.Localization.SourceGenerator` * **5 bundle meta-packages**: Essentials, Api, Documents, Notifications, SaaS ## Intentional design decisions [Section titled “Intentional design decisions”](#intentional-design-decisions) Several patterns in the dependency graph may look like anomalies during an audit but are deliberate choices. ### Isolated DbContext packages [Section titled “Isolated DbContext packages”](#isolated-dbcontext-packages) Seven `*.EntityFrameworkCore` packages use an autonomous `DbContext` via `IDbContextFactory` instead of the application `DbContext` managed by `Granit.Persistence`: Authorization, BackgroundJobs, Localization, Features, Settings, Webhooks, BlobStorage. These modules manage infrastructure data (not business entities), use `IDbContextFactory` for thread safety with parallel Wolverine handlers, and do not need `AuditedEntityInterceptor` or `SoftDeleteInterceptor`. ### Caching.Hybrid depends on StackExchangeRedis [Section titled “Caching.Hybrid depends on StackExchangeRedis”](#cachinghybrid-depends-on-stackexchangeredis) `HybridCache` (.NET 9+) requires a distributed L2 backend. Redis is the only backend supported by the Granit stack, making this a structural dependency. ### DataExchange depends on Querying [Section titled “DataExchange depends on Querying”](#dataexchange-depends-on-querying) The coupling is export-only: `DataExchange` reads `QueryDefinition` metadata to generate tabular exports (columns, filters, sort). The import pipeline uses no Querying types. ### DocumentGeneration.Excel depends on Templating [Section titled “DocumentGeneration.Excel depends on Templating”](#documentgenerationexcel-depends-on-templating) The XLSX package (ClosedXML) bypasses the HTML-to-render pipeline because XLSX is a binary format. It references `Templating` for `ITextTemplateRenderer` (Scriban cell rendering) and the polymorphic `RenderedContent` types. ### Notifications.Endpoints without RBAC [Section titled “Notifications.Endpoints without RBAC”](#notificationsendpoints-without-rbac) All notification endpoints are per-user self-service operations (inbox, preferences, subscriptions). Each endpoint filters by `GetUserId(user)` and cannot access another user’s data. `.RequireAuthorization()` without a RBAC policy is sufficient. # Frontend SDK > TypeScript and React SDK for Granit applications — framework-agnostic core with pluggable UI bindings The Granit Frontend SDK is a collection of 49 TypeScript packages that mirror the Granit .NET backend modules. Every package follows a **two-tier architecture**: 1. **TypeScript SDK** (`@granit/`) — framework-agnostic types, API functions, factories, and constants. Zero React dependency. Works with Angular, Vue, Svelte, or plain TypeScript. 2. **React bindings** (`@granit/react-`) — Provider components and hooks that wrap the TypeScript SDK for React applications. ## Architecture [Section titled “Architecture”](#architecture) ``` graph TD subgraph "Foundation" logger["@granit/logger"] utils["@granit/utils"] storage["@granit/storage"] cookies["@granit/cookies"] end subgraph "Infrastructure" api["@granit/api-client"] authn["@granit/react-authentication"] authz["@granit/react-authorization"] localization["@granit/localization"] tracing["@granit/tracing"] errorBoundary["@granit/error-boundary"] end subgraph "Business" querying["@granit/querying"] dataExchange["@granit/data-exchange"] workflow["@granit/workflow"] timeline["@granit/timeline"] notifications["@granit/notifications"] end authn --> api localization --> storage errorBoundary --> logger querying --> utils dataExchange --> utils timeline --> querying notifications --> querying ``` Data flows from types to API functions to hooks to providers: `types/ → api/ → hooks/ → providers/` ## Package inventory [Section titled “Package inventory”](#package-inventory) ### Foundation [Section titled “Foundation”](#foundation) | Package | Role | | -------------------------------------- | ------------------------------------------------------------- | | [`@granit/api-client`](./api-client/) | Axios factory, Bearer token interceptor, RFC 7807 error types | | [`@granit/idempotency`](./api-client/) | Automatic `Idempotency-Key` header injection | | [`@granit/utils`](./utils/) | Tailwind CSS class merging, date/number formatting | | [`@granit/logger`](./logger/) | Configurable logger factory with pluggable transports | | [`@granit/logger-otlp`](./logger/) | OTLP HTTP transport for log-to-trace correlation | ### Security [Section titled “Security”](#security) | Package | Role | | ------------------------------------------------------------ | ------------------------------------------------------- | | [`@granit/authentication`](./authentication/) | Keycloak/OIDC types (framework-agnostic) | | [`@granit/react-authentication`](./authentication/) | Keycloak init hook, auth context factory, mock provider | | [`@granit/authentication-api-keys`](./authentication/) | API key management types | | [`@granit/react-authentication-api-keys`](./authentication/) | React hooks for API key CRUD | | [`@granit/authorization`](./authorization/) | Permission and role types | | [`@granit/react-authorization`](./authorization/) | Permission checking hooks | | [`@granit/identity`](./identity/) | Identity provider capabilities | | [`@granit/react-identity`](./identity/) | Identity provider and hooks | | [`@granit/multi-tenancy`](./multi-tenancy/) | Tenant resolver abstraction | | [`@granit/react-multi-tenancy`](./multi-tenancy/) | Tenant provider and hooks | ### Data and messaging [Section titled “Data and messaging”](#data-and-messaging) | Package | Role | | ------------------------------------------------------------- | ---------------------------------------------------- | | [`@granit/notifications`](./notifications/) | Transport-agnostic notification types and API | | [`@granit/react-notifications`](./notifications/) | Notification provider and hooks | | [`@granit/notifications-signalr`](./notifications/) | SignalR real-time transport | | [`@granit/notifications-sse`](./notifications/) | Server-Sent Events transport | | [`@granit/notifications-web-push`](./notifications/) | Web Push VAPID subscription API | | [`@granit/react-notifications-web-push`](./notifications/) | Web Push React hook | | [`@granit/notifications-mobile-push`](./notifications/) | Mobile push token registration API | | [`@granit/react-notifications-mobile-push`](./notifications/) | Mobile push React hook | | [`@granit/querying`](./querying/) | Headless data grid types, filter system, saved views | | [`@granit/react-querying`](./querying/) | Query provider, pagination, smart filter hooks | | [`@granit/data-exchange`](./data-exchange/) | Import/export pipeline types and API | | [`@granit/react-data-exchange`](./data-exchange/) | Import/export providers and hooks | ### Domain [Section titled “Domain”](#domain) | Package | Role | | ----------------------------------------------------- | ------------------------------------------------------ | | [`@granit/workflow`](./workflow/) | FSM lifecycle types and API | | [`@granit/react-workflow`](./workflow/) | Workflow provider, status and transition hooks | | [`@granit/timeline`](./timeline/) | Activity stream types and API | | [`@granit/react-timeline`](./timeline/) | Timeline provider, stream and follower hooks | | [`@granit/templating`](./templating/) | Template editing, preview, lifecycle (unified package) | | [`@granit/background-jobs`](./background-jobs/) | Background job status types | | [`@granit/react-background-jobs`](./background-jobs/) | Job monitoring and control hooks | ### Cross-cutting [Section titled “Cross-cutting”](#cross-cutting) | Package | Role | | --------------------------------------------------- | ------------------------------------------- | | [`@granit/settings`](./settings/) | User/tenant/global settings types and API | | [`@granit/react-settings`](./settings/) | Settings provider and hooks | | [`@granit/reference-data`](./reference-data/) | i18n reference data types | | [`@granit/react-reference-data`](./reference-data/) | Reference data CRUD hooks | | [`@granit/localization`](./localization/) | i18next integration, locale resolution | | [`@granit/react-localization`](./localization/) | React localization bindings | | [`@granit/cookies`](./cookies/) | Cookie consent abstraction | | [`@granit/react-cookies`](./cookies/) | Cookie consent provider and hook | | [`@granit/cookies-klaro`](./cookies/) | Klaro CMP adapter | | [`@granit/storage`](./storage/) | Typed browser storage factory | | [`@granit/react-storage`](./storage/) | Synchronized storage hook | | [`@granit/tracing`](./tracing/) | OpenTelemetry browser tracing | | [`@granit/react-tracing`](./tracing/) | Tracing provider and span hooks | | [`@granit/error-boundary`](./error-boundary/) | Error context types | | [`@granit/react-error-boundary`](./error-boundary/) | Error boundary, global capture, breadcrumbs | ## Prerequisites [Section titled “Prerequisites”](#prerequisites) * **Node.js** 22+ and **pnpm** * **`@tanstack/react-query`** v5 — peer dependency of all React packages with data fetching * **Axios** v1.x — peer dependency of `@granit/api-client` ## .NET correspondence [Section titled “.NET correspondence”](#net-correspondence) Each frontend package mirrors a Granit .NET module: | .NET module | Frontend SDK | | -------------------------- | ---------------------------------------------------------- | | `Granit.Core` | `@granit/api-client`, `@granit/utils` | | `Granit.Security` | `@granit/authentication`, `@granit/react-authentication` | | `Granit.Authorization` | `@granit/authorization`, `@granit/react-authorization` | | `Granit.Identity` | `@granit/identity`, `@granit/react-identity` | | `Granit.MultiTenancy` | `@granit/multi-tenancy`, `@granit/react-multi-tenancy` | | `Granit.Notifications` | `@granit/notifications` + transport packages | | `Granit.Querying` | `@granit/querying`, `@granit/react-querying` | | `Granit.DataExchange` | `@granit/data-exchange`, `@granit/react-data-exchange` | | `Granit.Workflow` | `@granit/workflow`, `@granit/react-workflow` | | `Granit.Timeline` | `@granit/timeline`, `@granit/react-timeline` | | `Granit.Templating` | `@granit/templating` | | `Granit.BackgroundJobs` | `@granit/background-jobs`, `@granit/react-background-jobs` | | `Granit.Observability` | `@granit/logger`, `@granit/tracing` | | `Granit.Localization` | `@granit/localization`, `@granit/react-localization` | | `Granit.Cookies` | `@granit/cookies`, `@granit/cookies-klaro` | | `Granit.ExceptionHandling` | `@granit/error-boundary`, `@granit/react-error-boundary` | TypeScript types in each package mirror the corresponding .NET DTOs. Changes to a .NET endpoint contract must be propagated to the matching TypeScript type, and vice versa. # API Client > Axios factory with Bearer token interceptor, multi-tenant header, RFC 7807 error types, and orval mutator `@granit/api-client` is the HTTP foundation for all Granit frontend packages. It creates pre-configured Axios instances with automatic Bearer token injection, optional `X-Tenant-Id` header for multi-tenant applications, and a 401 response interceptor for back-channel logout. `@granit/idempotency` extends it with automatic `Idempotency-Key` header injection on mutation requests, pairing with `Granit.Idempotency` on the .NET backend. **Peer dependencies:** `axios ^1.0.0` ## Package structure [Section titled “Package structure”](#package-structure) | Package | Role | Depends on | | --------------------- | ----------------------------------------------------------------- | ----------------------------- | | `@granit/api-client` | `createApiClient()`, `createMutator()`, global interceptor wiring | `axios` | | `@granit/idempotency` | `enableIdempotency()` for mutation request deduplication | `@granit/api-client`, `axios` | ## Setup [Section titled “Setup”](#setup) * Standard ```ts import { createApiClient } from '@granit/api-client'; export const api = createApiClient({ baseURL: import.meta.env.VITE_API_URL, timeout: 15_000, // optional, default: 10_000 ms }); ``` * With orval mutator ```ts import { createApiClient, createMutator } from '@granit/api-client'; const api = createApiClient({ baseURL: import.meta.env.VITE_API_URL }); // orval-compatible mutator — reuses interceptors, returns response.data directly export const customInstance = createMutator(api); export default customInstance; ``` * With idempotency ```ts // src/main.ts — call once before any API request import { enableIdempotency } from '@granit/idempotency'; enableIdempotency(); // Every POST, PUT, PATCH, DELETE now includes: // Idempotency-Key: ``` ## API client [Section titled “API client”](#api-client) ### `createApiClient(config)` [Section titled “createApiClient(config)”](#createapiclientconfig) Creates an Axios instance with request interceptors for Bearer token and tenant header, plus a response interceptor for 401 handling. ```ts interface ApiClientConfig { baseURL: string; timeout?: number; // default: 10_000 ms } function createApiClient(config: ApiClientConfig): AxiosInstance; ``` The instance automatically: * Injects `Authorization: Bearer ` if a token getter is registered * Injects `X-Tenant-Id: ` if a tenant getter is registered * Calls the `onUnauthorized` callback on HTTP 401, then re-throws the error ### `createMutator(instance)` [Section titled “createMutator(instance)”](#createmutatorinstance) Creates an [orval-compatible mutator](https://orval.dev/guides/custom-client) from an existing Axios instance. The mutator reuses all interceptors and returns `response.data` directly. ```ts function createMutator( instance: AxiosInstance ): (config: AxiosRequestConfig, options?: AxiosRequestConfig) => Promise; ``` ### Global setters [Section titled “Global setters”](#global-setters) These functions wire global behavior into every Axios instance created by `createApiClient`. Most are called automatically by companion packages — manual use is only needed when those packages are not installed. | Function | Called automatically by | Purpose | | --------------------------------------- | ------------------------------ | -------------------------------------- | | `setTokenGetter(getter)` | `@granit/react-authentication` | Async Keycloak token getter | | `setTenantGetter(getter)` | `@granit/react-multi-tenancy` | Sync tenant ID for `X-Tenant-Id` | | `setOnUnauthorized(callback)` | `@granit/react-authentication` | HTTP 401 handler (back-channel logout) | | `setIdempotencyKeyGenerator(generator)` | `@granit/idempotency` | Mutation request key generator | ```ts function setTokenGetter(getter: () => Promise): void; function setTenantGetter(getter: () => string | undefined): void; function setOnUnauthorized(callback: () => void): void; function setIdempotencyKeyGenerator( generator: (config: InternalAxiosRequestConfig) => string | undefined ): void; ``` ## Error classes [Section titled “Error classes”](#error-classes) All error classes are structured for programmatic handling. They pair with the [RFC 7807 Problem Details](/reference/modules/core/#exception-hierarchy) returned by the Granit .NET backend. ### `HttpError` [Section titled “HttpError”](#httperror) Thrown when the backend returns a non-2xx response with a `ProblemDetails` body. ```ts class HttpError extends Error { readonly name = 'HttpError'; readonly status: number; readonly problemDetails?: ProblemDetailsPayload; } ``` ### `ValidationError` [Section titled “ValidationError”](#validationerror) Thrown for HTTP 422 responses with field-level validation errors. ```ts class ValidationError extends Error { readonly name = 'ValidationError'; readonly details?: ValidationDetails; } interface ValidationDetails { readonly field?: string; readonly constraint?: string; readonly fieldErrors?: Readonly>; } ``` ### `TimeoutError` [Section titled “TimeoutError”](#timeouterror) Thrown when a request exceeds the configured timeout. ```ts class TimeoutError extends Error { readonly name = 'TimeoutError'; readonly timeoutMs: number; } ``` ## Response types [Section titled “Response types”](#response-types) ```ts // RFC 7807 Problem Details — mirrors Granit.ExceptionHandling output interface ProblemDetails { type?: string; title: string; status: number; detail?: string; instance?: string; traceId?: string; // OpenTelemetry trace ID for Grafana correlation errorCode?: string; // Domain error code, e.g. "Appointment:SlotUnavailable" } // Paginated list — mirrors Granit.Querying PagedResult interface PaginatedResponse { items: T[]; total: number; page: number; pageSize: number; } ``` ## Idempotency [Section titled “Idempotency”](#idempotency) `@granit/idempotency` injects a unique `Idempotency-Key` header on every mutation request (`POST`, `PUT`, `PATCH`, `DELETE`). The .NET backend (`Granit.Idempotency`) uses this key to deduplicate requests — same key + same payload returns the cached response, same key + different payload returns HTTP 409. ```ts interface IdempotencyOptions { methods?: string[]; // default: ['post', 'put', 'patch', 'delete'] headerName?: string; // default: 'Idempotency-Key' keyGenerator?: (config: InternalAxiosRequestConfig) => string | undefined; } function enableIdempotency(options?: IdempotencyOptions): void; function disableIdempotency(): void; ``` The default key generator uses `crypto.randomUUID()`. Call `enableIdempotency()` once in `main.ts` — it registers the generator via `setIdempotencyKeyGenerator()` on the shared `@granit/api-client` instance. ## Test utilities [Section titled “Test utilities”](#test-utilities) Import from `@granit/api-client/test-utils` for Vitest: ```ts import { createMockClient, axiosResponse } from '@granit/api-client/test-utils'; const client = createMockClient(); vi.mocked(client.get).mockResolvedValue(axiosResponse({ items: [], total: 0 })); ``` | Function | Purpose | | --------------------- | ------------------------------------------------- | | `createMockClient()` | Fully mocked `AxiosInstance` with `vi.fn()` stubs | | `axiosResponse(data)` | Wraps data in a minimal `AxiosResponse` shape | ## Public API summary [Section titled “Public API summary”](#public-api-summary) | Category | Key exports | Package | | -------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------- | | Factory | `createApiClient()`, `createMutator()` | `@granit/api-client` | | Global setters | `setTokenGetter()`, `setTenantGetter()`, `setOnUnauthorized()`, `setIdempotencyKeyGenerator()` | `@granit/api-client` | | Error classes | `HttpError`, `ValidationError`, `TimeoutError` | `@granit/api-client` | | Response types | `ProblemDetails`, `ProblemDetailsPayload`, `ValidationDetails`, `PaginatedResponse`, `ApiClientConfig` | `@granit/api-client` | | Idempotency | `enableIdempotency()`, `disableIdempotency()`, `IdempotencyOptions` | `@granit/idempotency` | | Test utilities | `createMockClient()`, `axiosResponse()` | `@granit/api-client/test-utils` | ## See also [Section titled “See also”](#see-also) * [Granit.Core module](/reference/modules/core/) — .NET foundation (exception hierarchy, batch operations) * [Granit.Idempotency module](/reference/modules/api-web/) — .NET idempotency middleware * [Authentication](./authentication/) — `setTokenGetter` is wired automatically by `@granit/react-authentication` * [Multi-tenancy](./multi-tenancy/) — `setTenantGetter` is wired automatically by `@granit/react-multi-tenancy` # Authentication > Keycloak/OIDC authentication with PKCE, session lifecycle, back-channel logout, and API key management `@granit/authentication` defines framework-agnostic Keycloak/OIDC types — user claims, login/logout options, lifecycle events, and a base auth context interface. `@granit/react-authentication` provides the `useKeycloakInit` hook that handles PKCE initialization, silent SSO, automatic token refresh, and back-channel logout. It also exports `createAuthContext` and `createMockProvider` factories so each consuming app can define its own typed auth context. `@granit/authentication-api-keys` and `@granit/react-authentication-api-keys` add API key management — types and React Query hooks for creating, revoking, rotating, and scoping keys. **Peer dependencies:** `keycloak-js`, `react ^19`, `axios`, `@tanstack/react-query ^5` ## Package structure [Section titled “Package structure”](#package-structure) | Package | Role | Depends on | | --------------------------------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------- | | `@granit/authentication` | Keycloak types, login/logout options, lifecycle events | `keycloak-js` | | `@granit/react-authentication` | `useKeycloakInit`, `createAuthContext`, `createMockProvider` | `@granit/authentication`, `@granit/api-client`, `react`, `keycloak-js` | | `@granit/authentication-api-keys` | API key DTOs (`ApiKeyResponse`, `ApiKeyCreateRequest`, etc.) | — | | `@granit/react-authentication-api-keys` | `useApiKeys`, `useCreateApiKey`, `useRevokeApiKey`, `useRotateApiKey` | `@granit/authentication-api-keys`, `@tanstack/react-query`, `axios` | ## Dependency graph [Section titled “Dependency graph”](#dependency-graph) ``` graph TD authn["@granit/authentication"] reactAuthn["@granit/react-authentication"] apiKeys["@granit/authentication-api-keys"] reactApiKeys["@granit/react-authentication-api-keys"] apiClient["@granit/api-client"] keycloak["keycloak-js"] reactAuthn --> authn reactAuthn --> apiClient reactAuthn --> keycloak authn --> keycloak reactApiKeys --> apiKeys style keycloak fill:#e8f5e9,stroke:#43a047,color:#1b5e20 ``` ## Setup [Section titled “Setup”](#setup) * React (recommended) src/auth/auth-context.ts ```tsx import type { BaseAuthContextType, KeycloakUserInfo } from '@granit/authentication'; import { createAuthContext } from '@granit/react-authentication'; interface AuthContextType extends BaseAuthContextType { register: (options?: { redirectUri?: string }) => void; } export const { AuthContext, useAuth } = createAuthContext(); ``` src/auth/AuthProvider.tsx ```tsx import { useKeycloakInit } from '@granit/react-authentication'; import { AuthContext } from './auth-context'; export function AuthProvider({ children }: { children: React.ReactNode }) { const auth = useKeycloakInit({ url: import.meta.env.VITE_KEYCLOAK_URL, realm: import.meta.env.VITE_KEYCLOAK_REALM, clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID, }); return {children}; } ``` * TypeScript only ```ts import type { BaseAuthContextType, KeycloakUserInfo, LoginOptions, LogoutOptions, } from '@granit/authentication'; // Use the types to build your own auth layer (Angular, Vue, etc.) ``` ## TypeScript SDK [Section titled “TypeScript SDK”](#typescript-sdk) ### Types [Section titled “Types”](#types) #### `KeycloakUserInfo` [Section titled “KeycloakUserInfo”](#keycloakuserinfo) Standard OIDC user claims from the Keycloak `/userinfo` endpoint. ```ts interface KeycloakUserInfo { sub: string; email?: string; email_verified?: boolean; name?: string; preferred_username?: string; given_name?: string; family_name?: string; picture?: string; } ``` #### `KeycloakEvent` [Section titled “KeycloakEvent”](#keycloakevent) Keycloak lifecycle event names. ```ts type KeycloakEvent = | 'onReady' | 'onAuthSuccess' | 'onAuthError' | 'onAuthRefreshSuccess' | 'onAuthRefreshError' | 'onAuthLogout' | 'onTokenExpired'; ``` #### `LoginOptions` [Section titled “LoginOptions”](#loginoptions) ```ts interface LoginOptions { redirectUri?: string; idpHint?: string; // bypass Keycloak login page (e.g. "google") loginHint?: string; // pre-fill username locale?: string; // force login UI locale action?: string; // "register" or a required action name prompt?: 'login' | 'consent' | 'none'; scope?: string; // additional OAuth scopes maxAge?: number; // max seconds since last authentication } ``` #### `LogoutOptions` [Section titled “LogoutOptions”](#logoutoptions) ```ts interface LogoutOptions { redirectUri?: string; } ``` #### `BaseAuthContextType` [Section titled “BaseAuthContextType”](#baseauthcontexttype) Shared auth context interface. Each consuming app extends it with application-specific fields. ```ts interface BaseAuthContextType { keycloak: Keycloak | null; authenticated: boolean; loading: boolean; user: KeycloakUserInfo | null; login: () => void; logout: () => void; } ``` #### `KeycloakCoreConfig` [Section titled “KeycloakCoreConfig”](#keycloakcoreconfig) Configuration object for `useKeycloakInit`. ```ts interface KeycloakCoreConfig { url: string; realm: string; clientId: string; silentCheckSso?: boolean; // default: true silentCheckSsoFallback?: boolean; // default: true (Safari workaround) useTokenClaims?: boolean; // default: false (use /userinfo endpoint) onTokenExpired?: () => void; onAuthRefreshError?: () => void; onAuthLogout?: () => void; onEvent?: (event: KeycloakEvent) => void; } ``` ### API key types [Section titled “API key types”](#api-key-types) #### `ApiKeyType` [Section titled “ApiKeyType”](#apikeytype) ```ts type ApiKeyType = 'Secret' | 'Publishable' | 'Webhook' | 'Ephemeral'; ``` #### `CacheBehavior` [Section titled “CacheBehavior”](#cachebehavior) ```ts type CacheBehavior = 'Normal' | 'NoCache'; ``` #### `ApiKeyResponse` [Section titled “ApiKeyResponse”](#apikeyresponse) ```ts interface ApiKeyResponse { readonly id: string; readonly name: string; readonly type: ApiKeyType; readonly environment: string; readonly prefix: string; readonly lastFourChars: string; readonly permissions: readonly string[]; readonly allowedCidrs: readonly string[]; readonly expiresAt: string | null; readonly lastUsedAt: string | null; readonly revokedAt: string | null; readonly cacheBehavior: CacheBehavior; readonly createdAt: string; } ``` #### `ApiKeyCreateRequest` [Section titled “ApiKeyCreateRequest”](#apikeycreaterequest) ```ts interface ApiKeyCreateRequest { readonly name: string; readonly type: ApiKeyType; readonly environment: string; readonly permissions?: readonly string[]; readonly allowedCidrs?: readonly string[]; readonly expiresAt?: string; readonly cacheBehavior?: CacheBehavior; } ``` #### `ApiKeyCreateResponse` [Section titled “ApiKeyCreateResponse”](#apikeycreateresponse) ```ts interface ApiKeyCreateResponse { readonly id: string; readonly rawSecret: string; // only returned once — must be stored by client readonly prefix: string; readonly lastFourChars: string; readonly name: string; readonly type: ApiKeyType; readonly environment: string; readonly expiresAt: string | null; } ``` #### `ApiKeyRotateResponse` [Section titled “ApiKeyRotateResponse”](#apikeyrotateresponse) ```ts interface ApiKeyRotateResponse { readonly newKeyId: string; readonly rawSecret: string; // only returned once readonly prefix: string; readonly lastFourChars: string; readonly oldKeyId: string; } ``` #### `ApiKeyUpdateScopesRequest` [Section titled “ApiKeyUpdateScopesRequest”](#apikeyupdatescopesrequest) ```ts interface ApiKeyUpdateScopesRequest { readonly permissions: readonly string[]; readonly allowedCidrs: readonly string[]; } ``` ## React bindings [Section titled “React bindings”](#react-bindings) ### `useKeycloakInit(config)` [Section titled “useKeycloakInit(config)”](#usekeycloakinitconfig) Initializes Keycloak with PKCE (S256), performs `check-sso`, and manages the full session lifecycle. ```ts function useKeycloakInit(config: KeycloakCoreConfig): KeycloakCoreResult; ``` **Returned fields** (extends `BaseAuthContextType`): | Field | Type | Description | | ---------------------------------- | ----------------------------------------- | -------------------------------------------- | | `keycloakRef` | `RefObject` | Direct Keycloak instance reference | | `login(options?)` | `(LoginOptions?) => void` | Redirect to Keycloak login | | `logout(options?)` | `(LogoutOptions?) => void` | Redirect to Keycloak logout | | `register(options?)` | `(Omit?) => void` | Shortcut for `login({ action: 'register' })` | | `hasRealmRole(role)` | `(string) => boolean` | Check realm-level role | | `hasResourceRole(role, resource?)` | `(string, string?) => boolean` | Check resource-level role | | `isTokenExpired(minValidity?)` | `(number?) => boolean` | Check if token expires within `n` seconds | | `tokenParsed` | `Record \| undefined` | Decoded JWT payload | **Automatic side effects:** * Registers Bearer token getter via `setTokenGetter()` on `@granit/api-client` * Registers 401 handler via `setOnUnauthorized()` — triggers `keycloak.logout()` * Refreshes token every 60 seconds via `setInterval` * Loads user info from `/userinfo` endpoint (or from `tokenParsed` when `useTokenClaims: true`) ### Session lifecycle [Section titled “Session lifecycle”](#session-lifecycle) | Event | Effect | | ------------------------------------ | ------------------------------------------------------ | | Token expired | Auto-refresh handles it (60s interval) | | Refresh fails (`onAuthRefreshError`) | `authenticated → false`, forces logout | | Session revoked (`onAuthLogout`) | `authenticated → false`, `user → null` | | HTTP 401 received | `onUnauthorized` callback triggers `keycloak.logout()` | Back-channel logout (admin revokes session in Keycloak) is detected via the dual mechanism: the 60-second token refresh fails, or the next API call returns 401. ### `createAuthContext()` [Section titled “createAuthContext\()”](#createauthcontextt) Factory that creates a typed React context and `useAuth` hook. Each app calls this once with its own extended context type. ```ts function createAuthContext(): { AuthContext: React.Context; useAuth: () => T; }; ``` `useAuth()` throws if called outside `AuthContext.Provider`. ### `createMockProvider(AuthContext, value)` [Section titled “createMockProvider\(AuthContext, value)”](#createmockprovidertauthcontext-value) Creates a mock auth provider for Storybook and unit tests. ```tsx function createMockProvider( AuthContext: React.Context, value: T ): React.FC<{ children: React.ReactNode }>; ``` ```tsx const MockAuthProvider = createMockProvider(AuthContext, { keycloak: null, authenticated: true, loading: false, user: { sub: 'mock-001', name: 'Test User', email: 'test@example.com' }, login: () => {}, logout: () => {}, }); ``` ### API key hooks [Section titled “API key hooks”](#api-key-hooks) All hooks accept an `ApiKeyHookOptions` configuration: ```ts interface ApiKeyHookOptions { client: AxiosInstance; basePath?: string; // default: '/api/v1/api-keys' } ``` #### Query key factory [Section titled “Query key factory”](#query-key-factory) ```ts const apiKeyKeys = { all: ['api-keys'] as const, lists: () => [...apiKeyKeys.all, 'list'] as const, list: (params) => [...apiKeyKeys.lists(), params] as const, details: () => [...apiKeyKeys.all, 'detail'] as const, detail: (id) => [...apiKeyKeys.details(), id] as const, }; ``` #### `useApiKeys(params?, options)` [Section titled “useApiKeys(params?, options)”](#useapikeysparams-options) Fetches a paginated, filterable list of API keys. ```ts interface UseApiKeysParams { search?: string; type?: string[]; environment?: string; includeRevoked?: boolean; page?: number; pageSize?: number; } function useApiKeys( params?: UseApiKeysParams, options: ApiKeyHookOptions ): UseQueryResult; ``` #### `useApiKey(id, options)` [Section titled “useApiKey(id, options)”](#useapikeyid-options) Fetches a single API key by ID. Query is enabled only when `id` is truthy. ```ts function useApiKey(id: string, options: ApiKeyHookOptions): UseQueryResult; ``` #### `useCreateApiKey(options)` [Section titled “useCreateApiKey(options)”](#usecreateapikeyoptions) Creates a new API key. Returns `ApiKeyCreateResponse` with `rawSecret` — **only available once**. ```ts function useCreateApiKey( options: ApiKeyHookOptions ): UseMutationResult; ``` #### `useRevokeApiKey(options)` [Section titled “useRevokeApiKey(options)”](#userevokeapikeyoptions) Revokes an API key. Invalidates list and detail caches on success. ```ts function useRevokeApiKey( options: ApiKeyHookOptions ): UseMutationResult; ``` #### `useRotateApiKey(options)` [Section titled “useRotateApiKey(options)”](#userotateapikeyoptions) Rotates an API key — creates a new secret and invalidates the old one. ```ts function useRotateApiKey( options: ApiKeyHookOptions ): UseMutationResult; ``` #### `useUpdateApiKeyScopes(options)` [Section titled “useUpdateApiKeyScopes(options)”](#useupdateapikeyscopesoptions) Updates permissions and allowed CIDRs on an existing key. ```ts interface UpdateApiKeyScopesVariables { id: string; request: ApiKeyUpdateScopesRequest; } function useUpdateApiKeyScopes( options: ApiKeyHookOptions ): UseMutationResult; ``` ## Public API summary [Section titled “Public API summary”](#public-api-summary) | Category | Key exports | Package | | --------------- | -------------------------------------------------------------------------------------------- | --------------------------------------- | | OIDC types | `KeycloakUserInfo`, `KeycloakEvent`, `LoginOptions`, `LogoutOptions` | `@granit/authentication` | | Auth context | `BaseAuthContextType`, `KeycloakCoreConfig` | `@granit/authentication` | | Keycloak hook | `useKeycloakInit()`, `KeycloakCoreResult` | `@granit/react-authentication` | | Context factory | `createAuthContext()`, `createMockProvider()` | `@granit/react-authentication` | | API key types | `ApiKeyResponse`, `ApiKeyCreateRequest`, `ApiKeyCreateResponse`, `ApiKeyRotateResponse` | `@granit/authentication-api-keys` | | API key hooks | `useApiKeys()`, `useApiKey()`, `useCreateApiKey()`, `useRevokeApiKey()`, `useRotateApiKey()` | `@granit/react-authentication-api-keys` | | Query keys | `apiKeyKeys` | `@granit/react-authentication-api-keys` | ## See also [Section titled “See also”](#see-also) * [Granit.Security module](/reference/modules/security/) — .NET authentication and JWT Bearer middleware * [API client](./api-client/) — `setTokenGetter` and `setOnUnauthorized` are wired automatically * [Authorization](./authorization/) — Permission checking after authentication * [Multi-tenancy](./multi-tenancy/) — Tenant resolution from JWT claims # Authorization > Permission checking with O(1) lookups, role-permission management, and admin hooks for grant/revoke `@granit/authorization` defines permission and role types — DTOs for permission definitions, grants, and groups. `@granit/react-authorization` provides React Query hooks for checking the current user’s permissions (with O(1) lookups), browsing the permission definitions tree, and managing role-permission grants. **Peer dependencies:** `axios`, `react ^19`, `@tanstack/react-query ^5` ## Package structure [Section titled “Package structure”](#package-structure) | Package | Role | Depends on | | ----------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------ | | `@granit/authorization` | Permission DTOs, option types | `axios` | | `@granit/react-authorization` | `usePermissions`, `usePermissionDefinitions`, `usePermissionGrant` | `@granit/authorization`, `@tanstack/react-query`, `axios`, `react` | ## Dependency graph [Section titled “Dependency graph”](#dependency-graph) ``` graph TD authz["@granit/authorization"] reactAuthz["@granit/react-authorization"] apiClient["@granit/api-client"] reactAuthz --> authz reactAuthz --> apiClient ``` ## Setup [Section titled “Setup”](#setup) * React (recommended) ```tsx import { usePermissions } from '@granit/react-authorization'; import { api } from './api-client'; function ProtectedButton() { const { hasPermission, isLoading } = usePermissions({ client: api }); if (isLoading) return ; if (!hasPermission('Invoices.Delete')) return null; return ; } ``` * TypeScript only ```ts import type { PermissionDefinitionDto, PermissionGroupDto, PermissionGrantDto, } from '@granit/authorization'; // Use the types to build your own permission layer (Angular, Vue, etc.) ``` ## TypeScript SDK [Section titled “TypeScript SDK”](#typescript-sdk) ### `PermissionsResponse` [Section titled “PermissionsResponse”](#permissionsresponse) ```ts type PermissionsResponse = { permissions: readonly string[]; }; ``` ### `PermissionDefinitionDto` [Section titled “PermissionDefinitionDto”](#permissiondefinitiondto) A single permission definition. ```ts type PermissionDefinitionDto = { name: string; displayName: string | null; }; ``` ### `PermissionGroupDto` [Section titled “PermissionGroupDto”](#permissiongroupdto) A group of related permissions (one per module). ```ts type PermissionGroupDto = { name: string; displayName: string | null; permissions: readonly PermissionDefinitionDto[]; }; ``` ### `PermissionGrantDto` [Section titled “PermissionGrantDto”](#permissiongrantdto) Permissions granted to a specific role. ```ts type PermissionGrantDto = { roleName: string; permissions: readonly string[]; }; ``` ### `PermissionGrantParams` [Section titled “PermissionGrantParams”](#permissiongrantparams) Parameters for grant/revoke mutations. ```ts type PermissionGrantParams = { roleName: string; permissionName: string; }; ``` ### Option types [Section titled “Option types”](#option-types) ```ts type UsePermissionsOptions = { client: AxiosInstance; basePath?: string; // default: '/api/v1/auth' enabled?: boolean; // default: true }; type UsePermissionDefinitionsOptions = { client: AxiosInstance; basePath?: string; enabled?: boolean; }; type UseRolePermissionsOptions = { client: AxiosInstance; roleName: string; basePath?: string; enabled?: boolean; }; type UsePermissionGrantOptions = { client: AxiosInstance; basePath?: string; }; ``` ## React bindings [Section titled “React bindings”](#react-bindings) ### Query key factory [Section titled “Query key factory”](#query-key-factory) ```ts const permissionKeys = { all: ['auth', 'permissions'] as const, me: (userId?) => [...permissionKeys.all, 'me', userId] as const, definitions: () => [...permissionKeys.all, 'definitions'] as const, role: (roleName) => [...permissionKeys.all, 'roles', roleName] as const, }; ``` ### `usePermissions(options)` [Section titled “usePermissions(options)”](#usepermissionsoptions) Fetches the current user’s granted permissions from `GET {basePath}/me` and returns a `ReadonlySet` for O(1) lookups. ```ts function usePermissions(options: UsePermissionsOptions): UsePermissionsReturn; type UsePermissionsReturn = { readonly permissions: ReadonlySet; hasPermission: (permission: string) => boolean; hasAnyPermission: (permissions: readonly string[]) => boolean; hasAllPermissions: (permissions: readonly string[]) => boolean; readonly isLoading: boolean; readonly error: Error | null; readonly refetch: () => void; }; ``` **Caching:** `staleTime: Infinity` — permissions are cached for the lifetime of the Keycloak session. Returns an empty set while loading (safe default: no access while uncertain). ```tsx const { hasPermission, hasAnyPermission } = usePermissions({ client: api }); // Single permission check if (hasPermission('Invoices.Delete')) { /* ... */ } // Any of several permissions if (hasAnyPermission(['Invoices.Read', 'Reports.Read'])) { /* ... */ } ``` ### `usePermissionDefinitions(options)` [Section titled “usePermissionDefinitions(options)”](#usepermissiondefinitionsoptions) Fetches the complete permission definitions tree from `GET {basePath}/definitions`. Used for admin UIs (role-permission matrix). ```ts function usePermissionDefinitions( options: UsePermissionDefinitionsOptions ): UseQueryResult; ``` Returns groups organized by module, each containing permission definitions with optional display names. Cached with `staleTime: 5 minutes`. ### `useRolePermissions(options)` [Section titled “useRolePermissions(options)”](#userolepermissionsoptions) Fetches permissions granted to a specific role from `GET {basePath}/roles/{roleName}`. ```ts function useRolePermissions( options: UseRolePermissionsOptions ): UseQueryResult; ``` URL-encodes `roleName` for safe handling of special characters (e.g. accented names). ### `usePermissionGrant(options)` [Section titled “usePermissionGrant(options)”](#usepermissiongrantoptions) Provides mutations for granting and revoking individual permissions on a role. ```ts function usePermissionGrant(options: UsePermissionGrantOptions): UsePermissionGrantReturn; type UsePermissionGrantReturn = { readonly grant: UseMutationResult; readonly revoke: UseMutationResult; }; ``` **API contracts:** | Action | HTTP method | Endpoint | | ------ | ----------- | ---------------------------------------------------------- | | Grant | `PUT` | `{basePath}/roles/{roleName}/permissions/{permissionName}` | | Revoke | `DELETE` | `{basePath}/roles/{roleName}/permissions/{permissionName}` | Both mutations automatically invalidate `permissionKeys.role(roleName)` on success. ```tsx const { grant, revoke } = usePermissionGrant({ client: api }); // Grant a permission grant.mutate({ roleName: 'editor', permissionName: 'Invoices.Create' }); // Revoke a permission revoke.mutate({ roleName: 'editor', permissionName: 'Invoices.Delete' }); ``` ## Public API summary [Section titled “Public API summary”](#public-api-summary) | Category | Key exports | Package | | ---------------- | -------------------------------------------------------------------------------------------------------------------- | ----------------------------- | | Permission types | `PermissionDefinitionDto`, `PermissionGroupDto`, `PermissionGrantDto`, `PermissionsResponse` | `@granit/authorization` | | Option types | `UsePermissionsOptions`, `UsePermissionDefinitionsOptions`, `UseRolePermissionsOptions`, `UsePermissionGrantOptions` | `@granit/authorization` | | User permissions | `usePermissions()`, `UsePermissionsReturn` | `@granit/react-authorization` | | Admin hooks | `usePermissionDefinitions()`, `useRolePermissions()`, `usePermissionGrant()` | `@granit/react-authorization` | | Query keys | `permissionKeys` | `@granit/react-authorization` | ## See also [Section titled “See also”](#see-also) * [Granit.Authorization module](/reference/modules/security/) — .NET policy-based authorization * [Authentication](./authentication/) — Must be authenticated before checking permissions * [Identity](./identity/) — Provider capabilities may affect role management # Background Jobs > Job status monitoring with auto-polling, pause/resume, and manual trigger `@granit/background-jobs` provides framework-agnostic types for background job monitoring — mirroring `Granit.BackgroundJobs` on the .NET backend. `@granit/react-background-jobs` wraps these into React hooks with TanStack Query integration: auto-polling job status every 15 seconds, plus pause/resume/trigger mutations. **Peer dependencies:** `axios`, `react ^19`, `@tanstack/react-query ^5` ## Package structure [Section titled “Package structure”](#package-structure) | Package | Role | Depends on | | ------------------------------- | ------------------------------------------------------------------- | -------------------------------------------------------------------- | | `@granit/background-jobs` | `BackgroundJobStatus` type definition | — | | `@granit/react-background-jobs` | `useBackgroundJobs`, `usePauseJob`, `useResumeJob`, `useTriggerJob` | `@granit/background-jobs`, `@tanstack/react-query`, `axios`, `react` | ## Setup [Section titled “Setup”](#setup) * React (recommended) ```tsx import { useBackgroundJobs, usePauseJob, useResumeJob, useTriggerJob } from '@granit/react-background-jobs'; import { api } from './api-client'; function JobDashboard() { const { data: jobs, isLoading } = useBackgroundJobs({ client: api }); const { mutate: pause } = usePauseJob({ client: api }); const { mutate: resume } = useResumeJob({ client: api }); const { mutate: trigger } = useTriggerJob({ client: api }); // Render job list with action buttons... } ``` * TypeScript only ```ts import type { BackgroundJobStatus } from '@granit/background-jobs'; ``` ## TypeScript SDK [Section titled “TypeScript SDK”](#typescript-sdk) ### Types [Section titled “Types”](#types) ```ts interface BackgroundJobStatus { readonly jobName: string; readonly cronExpression: string; readonly isEnabled: boolean; readonly lastExecutedAt: string | null; readonly nextExecutionAt: string | null; readonly consecutiveFailures: number; readonly deadLetterCount: number; readonly lastError: string | null; } ``` ### REST endpoints [Section titled “REST endpoints”](#rest-endpoints) | Method | Endpoint | Description | | ------ | ------------------------------------ | --------------------------------- | | `GET` | `/background-jobs` | List all jobs with current status | | `POST` | `/background-jobs/{jobName}/pause` | Pause a job | | `POST` | `/background-jobs/{jobName}/resume` | Resume a paused job | | `POST` | `/background-jobs/{jobName}/trigger` | Manually trigger a job | ## React bindings [Section titled “React bindings”](#react-bindings) ### Options [Section titled “Options”](#options) All hooks accept the same options object: ```ts interface BackgroundJobsOptions { readonly client: AxiosInstance; readonly basePath?: string; // default: '/api/v1/background-jobs' } ``` ### `useBackgroundJobs(options)` [Section titled “useBackgroundJobs(options)”](#usebackgroundjobsoptions) Fetches all background jobs with 15-second auto-polling. ```ts function useBackgroundJobs( options: BackgroundJobsOptions ): UseQueryResult; ``` ### `usePauseJob(options)` [Section titled “usePauseJob(options)”](#usepausejoboptions) Pauses a job by name. Invalidates the job list on success. ```ts function usePauseJob( options: BackgroundJobsOptions ): UseMutationResult; ``` ### `useResumeJob(options)` [Section titled “useResumeJob(options)”](#useresumejoboptions) Resumes a paused job by name. Invalidates the job list on success. ```ts function useResumeJob( options: BackgroundJobsOptions ): UseMutationResult; ``` ### `useTriggerJob(options)` [Section titled “useTriggerJob(options)”](#usetriggerjoboptions) Manually triggers a job by name. Invalidates the job list on success. ```ts function useTriggerJob( options: BackgroundJobsOptions ): UseMutationResult; ``` ### Query keys [Section titled “Query keys”](#query-keys) ```ts const backgroundJobKeys = { all: ['background-jobs'] as const, list: () => [...backgroundJobKeys.all, 'list'] as const, job: (name: string) => [...backgroundJobKeys.all, 'job', name] as const, }; ``` ## Public API summary [Section titled “Public API summary”](#public-api-summary) | Category | Key exports | Package | | ---------- | ---------------------------------------------------- | ------------------------------- | | Types | `BackgroundJobStatus` | `@granit/background-jobs` | | Query hook | `useBackgroundJobs()` | `@granit/react-background-jobs` | | Mutations | `usePauseJob()`, `useResumeJob()`, `useTriggerJob()` | `@granit/react-background-jobs` | | Cache keys | `backgroundJobKeys` | `@granit/react-background-jobs` | ## See also [Section titled “See also”](#see-also) * [Granit.BackgroundJobs module](/reference/modules/blob-storage/) — .NET background job scheduling with Wolverine and Cronos * [Data Exchange](./data-exchange/) — Import/export jobs run as background jobs # Cookies > RGPD cookie consent with pluggable CMP adapter, Klaro integration, and React context provider `@granit/cookies` defines an abstract `CookieConsentProvider` interface and RGPD category types — mirroring `Granit.Cookies` on the .NET backend. `@granit/react-cookies` wraps this into a React context. `@granit/cookies-klaro` provides a concrete Klaro CMP adapter. The architecture is pluggable: swap `@granit/cookies-klaro` for any other CMP (Cookiebot, OneTrust, etc.) by implementing `CookieConsentProvider`. **Peer dependencies:** `react ^19` ## Package structure [Section titled “Package structure”](#package-structure) | Package | Role | Depends on | | ----------------------- | ----------------------------------------------------------------- | -------------------------- | | `@granit/cookies` | `CookieConsentProvider` interface, `CookieCategory`, backend DTOs | — | | `@granit/react-cookies` | React context provider, `useCookieConsent` hook | `@granit/cookies`, `react` | | `@granit/cookies-klaro` | `createKlaroCookieConsentProvider()` factory | `@granit/cookies`, `klaro` | ## Setup [Section titled “Setup”](#setup) * React + Klaro ```tsx import { CookieConsentProvider } from '@granit/react-cookies'; import { createKlaroCookieConsentProvider } from '@granit/cookies-klaro'; const cmp = createKlaroCookieConsentProvider({ loadConfig: () => fetch('/api/v1/cookies/config').then(r => r.json()), }); function App({ children }) { return ( {children} ); } ``` * TypeScript only ```ts import type { CookieCategory, CookieConsentProvider, ConsentState, } from '@granit/cookies'; ``` ## TypeScript SDK [Section titled “TypeScript SDK”](#typescript-sdk) ### Types [Section titled “Types”](#types) ```ts type CookieCategory = 'strictly_necessary' | 'preferences' | 'analytics' | 'marketing'; type ConsentState = Record; interface CookieConsentProvider { init(): Promise; getConsents(): ConsentState; onConsentChange(callback: (consents: ConsentState) => void): () => void; setConsent(category: CookieCategory, granted: boolean): void; setAllConsents(granted: boolean): void; hasConsented(): boolean; } ``` ### Backend DTOs [Section titled “Backend DTOs”](#backend-dtos) ```ts interface CookieConsentConfig { readonly cookies: readonly CookieDefinitionDto[]; readonly services: readonly ThirdPartyServiceDto[]; } interface CookieDefinitionDto { readonly name: string; readonly category: CookieCategory; readonly retentionDays: number; readonly purpose: string; } interface ThirdPartyServiceDto { readonly name: string; readonly category: CookieCategory; readonly cookiePatterns: readonly string[]; } ``` The backend returns `CookieConsentConfig` from `GET /api/v1/cookies/config`. CMP adapters translate this into their own configuration format. ## React bindings [Section titled “React bindings”](#react-bindings) ### `CookieConsentProvider` (component) [Section titled “CookieConsentProvider (component)”](#cookieconsentprovider-component) ```tsx {children} ``` Initializes the CMP on mount and exposes consent state to the component tree. ### `useCookieConsent()` [Section titled “useCookieConsent()”](#usecookieconsent) ```ts interface CookieConsentContextValue { consents: ConsentState; isLoaded: boolean; hasConsented: boolean; acceptCategory: (category: CookieCategory) => void; revokeCategory: (category: CookieCategory) => void; acceptAll: () => void; revokeAll: () => void; } function useCookieConsent(): CookieConsentContextValue; ``` `strictly_necessary` is always granted and cannot be revoked. ## Klaro adapter [Section titled “Klaro adapter”](#klaro-adapter) ### `createKlaroCookieConsentProvider(options)` [Section titled “createKlaroCookieConsentProvider(options)”](#createklarocookieconsentprovideroptions) Creates a `CookieConsentProvider` backed by Klaro CMP. ```ts interface CreateKlaroCookieConsentProviderOptions { readonly klaroConfig?: KlaroConfig; readonly serviceMappings?: readonly KlaroServiceMapping[]; readonly loadConfig?: () => Promise; readonly cookieName?: string; } interface KlaroServiceMapping { readonly name: string; readonly category: CookieCategory; } ``` Two modes: * **Static**: Pass `klaroConfig` and `serviceMappings` directly * **Dynamic**: Pass `loadConfig` to fetch `CookieConsentConfig` from the backend API at init time ## Public API summary [Section titled “Public API summary”](#public-api-summary) | Category | Key exports | Package | | -------------- | ------------------------------------------------------------------------------ | ----------------------- | | Types | `CookieCategory`, `ConsentState`, `CookieConsentConfig`, `CookieDefinitionDto` | `@granit/cookies` | | Interface | `CookieConsentProvider` | `@granit/cookies` | | React provider | `CookieConsentProvider` (component) | `@granit/react-cookies` | | React hook | `useCookieConsent()` | `@granit/react-cookies` | | Klaro adapter | `createKlaroCookieConsentProvider()` | `@granit/cookies-klaro` | ## See also [Section titled “See also”](#see-also) * [Granit.Cookies module](/reference/modules/security/) — .NET cookie consent management with Klaro integration # Data Exchange > Import/export pipelines with file upload, column mapping, dry-run validation, export presets, and background job polling `@granit/data-exchange` provides framework-agnostic types and API functions for the import/export pipelines — mirroring `Granit.DataExchange` on the .NET backend. `@granit/react-data-exchange` wraps these into React providers and hooks that manage the full lifecycle: file upload → preview → column mapping → dry-run → execute → report. For exports: definition discovery → field selection → job creation → status polling → auto-download. **Peer dependencies:** `axios`, `@granit/utils`, `react ^19`, `@tanstack/react-query ^5` ## Package structure [Section titled “Package structure”](#package-structure) | Package | Role | Depends on | | ----------------------------- | --------------------------------------------------- | --------------------------------------------------------- | | `@granit/data-exchange` | DTOs, import/export API functions | `axios`, `@granit/utils` | | `@granit/react-data-exchange` | `ExportProvider`, `ImportProvider`, lifecycle hooks | `@granit/data-exchange`, `@tanstack/react-query`, `react` | ## Setup [Section titled “Setup”](#setup) * React (recommended) ```tsx import { ExportProvider, ImportProvider } from '@granit/react-data-exchange'; import { api } from './api-client'; function DataManagement({ children }) { return ( {children} ); } ``` * TypeScript only ```ts import { createExportJob, uploadImportFile } from '@granit/data-exchange'; import type { ExportJobResponse, ImportJobResponse } from '@granit/data-exchange'; ``` ## TypeScript SDK — Export [Section titled “TypeScript SDK — Export”](#typescript-sdk--export) ### Types [Section titled “Types”](#types) ```ts type ExportJobStatus = 'Queued' | 'Exporting' | 'Completed' | 'Failed'; interface ExportJobResponse { readonly id: string; readonly definitionName: string; readonly format: string; readonly status: ExportJobStatus; readonly rowCount: number | null; readonly fileName: string | null; readonly errorMessage: string | null; readonly createdAt: string; readonly completedAt: string | null; } interface CreateExportJobRequest { readonly definitionName: string; readonly format: string; readonly selectedFields: readonly string[] | null; readonly includeIdForImport: boolean; readonly sort: string | null; readonly filter: Readonly> | null; readonly presets: Readonly> | null; readonly search: string | null; } interface ExportFieldDescriptor { readonly propertyPath: string; readonly clrTypeName: string; readonly header: string | null; readonly format: string | null; readonly order: number; readonly isNavigation: boolean; } interface ExportDefinitionResponse { readonly name: string; readonly entityType: string; readonly supportedFormats: readonly string[]; } interface ExportPresetResponse { readonly definitionName: string; readonly presetName: string; readonly selectedFields: readonly string[]; readonly format: string; readonly includeIdForImport: boolean; } ``` ### API functions [Section titled “API functions”](#api-functions) | Function | Endpoint | Description | | ---------------------------------------------------- | --------------------------------- | ---------------------------------- | | `fetchExportDefinitions(client, basePath)` | `GET /definitions` | List registered export definitions | | `fetchExportFields(client, basePath, name)` | `GET /definitions/{name}/fields` | Available fields for a definition | | `createExportJob(client, basePath, request)` | `POST /jobs` | Create an export job | | `fetchExportJobStatus(client, basePath, jobId)` | `GET /jobs/{jobId}` | Poll job status | | `downloadExportFile(client, basePath, jobId)` | `GET /jobs/{jobId}/download` | Download generated file | | `fetchExportPresets(client, basePath, name)` | `GET /presets/{name}` | Saved presets for a definition | | `saveExportPreset(client, basePath, request)` | `POST /presets` | Save/update a preset | | `deleteExportPreset(client, basePath, name, preset)` | `DELETE /presets/{name}/{preset}` | Delete a preset | ## TypeScript SDK — Import [Section titled “TypeScript SDK — Import”](#typescript-sdk--import) ### Types [Section titled “Types”](#types-1) ```ts type ImportJobStatus = | 'Created' | 'Previewed' | 'Mapped' | 'Executing' | 'Completed' | 'PartiallyCompleted' | 'Failed' | 'Cancelled'; interface ImportJobResponse { readonly id: string; readonly definitionName: string; readonly originalFileName: string; readonly mimeType: string; readonly fileSizeBytes: number; readonly status: ImportJobStatus; readonly createdAt: string; readonly completedAt: string | null; } type MappingConfidence = 'Manual' | 'Saved' | 'Exact' | 'Fuzzy' | 'Semantic'; interface ImportColumnMapping { readonly sourceColumn: string; readonly targetProperty: string | null; readonly confidence: MappingConfidence; } interface ImportFieldMetadata { readonly propertyPath: string; readonly clrTypeName: string; readonly displayName: string; readonly description: string | null; readonly isRequired: boolean; } interface ImportPreviewResponse { readonly headers: readonly string[]; readonly previewRows: readonly (readonly string[])[]; readonly suggestions: readonly ImportColumnMapping[]; readonly fieldMetadata: readonly ImportFieldMetadata[]; } interface ImportReportResponse { readonly importJobId: string; readonly finalStatus: ImportJobStatus; readonly totalRows: number; readonly succeededRows: number; readonly failedRows: number; readonly skippedRows: number; readonly insertedRows: number; readonly updatedRows: number; readonly duration: string; readonly rowErrors: readonly ImportRowError[]; } type ImportRowErrorKind = 'Conversion' | 'Validation' | 'Persistence' | 'Identity'; interface ImportRowError { readonly rowNumber: number; readonly kind: ImportRowErrorKind; readonly errorCodes: readonly string[]; readonly message: string; } ``` ### API functions [Section titled “API functions”](#api-functions-1) | Function | Endpoint | Description | | ---------------------------------------------------------- | ------------------------------ | ------------------------------------------------- | | `uploadImportFile(client, basePath, file, definitionName)` | `POST /` | Upload file (multipart) | | `previewImport(client, basePath, jobId)` | `POST /{jobId}/preview` | Extract headers, sample rows, mapping suggestions | | `confirmMappings(client, basePath, jobId, request)` | `PUT /{jobId}/mappings` | Lock in column mappings | | `executeImport(client, basePath, jobId)` | `POST /{jobId}/execute` | Start async execution | | `dryRunImport(client, basePath, jobId)` | `POST /{jobId}/dry-run` | Validate without persisting | | `fetchImportJob(client, basePath, jobId)` | `GET /{jobId}` | Poll job status | | `cancelImportJob(client, basePath, jobId)` | `DELETE /{jobId}` | Cancel a job | | `fetchImportReport(client, basePath, jobId)` | `GET /{jobId}/report` | Full execution report | | `downloadCorrectionFile(client, basePath, jobId)` | `GET /{jobId}/correction-file` | Download error rows file | ## React bindings [Section titled “React bindings”](#react-bindings) ### Export hooks [Section titled “Export hooks”](#export-hooks) #### `useExportDefinitions()` [Section titled “useExportDefinitions()”](#useexportdefinitions) Fetches all registered export definitions. Cached 5 minutes. #### `useExportFields(definitionName)` [Section titled “useExportFields(definitionName)”](#useexportfieldsdefinitionname) Fetches available fields for a definition. Enabled when `definitionName` is defined. #### `useExportJob()` [Section titled “useExportJob()”](#useexportjob) Full export lifecycle: create job → poll status (2s) → auto-download on completion. ```ts interface UseExportJobReturn { readonly startExport: (request: CreateExportJobRequest) => void; readonly job: ExportJobResponse | null; readonly isExporting: boolean; readonly isCreating: boolean; readonly error: Error | null; readonly reset: () => void; } ``` #### `useExportPresets(definitionName)` [Section titled “useExportPresets(definitionName)”](#useexportpresetsdefinitionname) CRUD for export presets per definition. ```ts interface UseExportPresetsReturn { readonly presets: UseQueryResult; readonly save: UseMutationResult; readonly remove: UseMutationResult; } ``` ### Import hooks [Section titled “Import hooks”](#import-hooks) #### `useImportJob()` [Section titled “useImportJob()”](#useimportjob) Full import lifecycle: upload → preview → confirm mappings → execute → poll until terminal. ```ts interface UseImportJobReturn { readonly upload: (file: File, definitionName: string) => void; readonly confirmMap: (request: ConfirmMappingsRequest) => void; readonly execute: () => void; readonly cancel: () => void; readonly job: ImportJobResponse | null; readonly isTerminal: boolean; readonly isUploading: boolean; readonly isConfirming: boolean; readonly isExecuting: boolean; readonly isPolling: boolean; readonly error: Error | null; readonly reset: () => void; } ``` #### `useImportPreview()` [Section titled “useImportPreview()”](#useimportpreview) Preview management: extract headers, edit mappings, dry-run validation. ```ts interface UseImportPreviewReturn { readonly preview: (jobId: string) => void; readonly headers: readonly string[]; readonly previewRows: readonly (readonly string[])[]; readonly suggestions: readonly ImportColumnMapping[]; readonly fieldMetadata: readonly ImportFieldMetadata[]; readonly mappings: ImportColumnMapping[]; readonly updateMapping: (sourceColumn: string, targetProperty: string | null) => void; readonly dryRun: (jobId: string) => void; readonly dryRunReport: ImportReportResponse | null; readonly isPreviewing: boolean; readonly isDryRunning: boolean; readonly error: Error | null; readonly reset: () => void; } ``` #### `useImportReport(jobId)` [Section titled “useImportReport(jobId)”](#useimportreportjobid) Fetches the execution report and provides correction file download. ```ts interface UseImportReportReturn { readonly report: UseQueryResult; readonly downloadCorrection: () => Promise; } ``` ### Import lifecycle [Section titled “Import lifecycle”](#import-lifecycle) ``` graph LR Upload["Upload file"] --> Preview["Preview & map"] Preview --> DryRun["Dry-run"] DryRun --> Execute["Execute"] Execute --> Poll["Poll status"] Poll --> Report["Report"] Report --> Correction["Correction file"] DryRun -.->|fix mappings| Preview ``` ### Mapping confidence [Section titled “Mapping confidence”](#mapping-confidence) | Level | Source | Description | | ---------- | ------ | ---------------------------- | | `Exact` | Auto | Exact column name match | | `Fuzzy` | Auto | Edit-distance matching | | `Semantic` | Auto | AI-powered semantic matching | | `Saved` | Preset | From a saved mapping preset | | `Manual` | User | User-edited mapping | ## Public API summary [Section titled “Public API summary”](#public-api-summary) | Category | Key exports | Package | | -------------- | -------------------------------------------------------------------------------------------------- | ----------------------------- | | Export types | `ExportJobResponse`, `CreateExportJobRequest`, `ExportFieldDescriptor`, `ExportDefinitionResponse` | `@granit/data-exchange` | | Export API | `createExportJob()`, `fetchExportJobStatus()`, `downloadExportFile()` | `@granit/data-exchange` | | Export presets | `ExportPresetResponse`, `saveExportPreset()`, `deleteExportPreset()` | `@granit/data-exchange` | | Import types | `ImportJobResponse`, `ImportPreviewResponse`, `ImportReportResponse`, `ImportColumnMapping` | `@granit/data-exchange` | | Import API | `uploadImportFile()`, `previewImport()`, `confirmMappings()`, `executeImport()`, `dryRunImport()` | `@granit/data-exchange` | | Export hooks | `useExportJob()`, `useExportDefinitions()`, `useExportFields()`, `useExportPresets()` | `@granit/react-data-exchange` | | Import hooks | `useImportJob()`, `useImportPreview()`, `useImportReport()` | `@granit/react-data-exchange` | | Providers | `ExportProvider`, `ImportProvider` | `@granit/react-data-exchange` | ## See also [Section titled “See also”](#see-also) * [Granit.DataExchange module](/reference/modules/data-exchange/) — .NET import/export pipeline (Extract → Map → Validate → Execute) * [Querying](./querying/) — Export jobs apply the same filter/sort/preset parameters # Error Boundary > Headless error boundary with breadcrumb trail, global error capture, and structured error context `@granit/error-boundary` provides framework-agnostic types for error context enrichment — breadcrumb trails, route info, and user identity. `@granit/react-error-boundary` wraps these into a headless React error boundary, a global error capture component, and a breadcrumb context provider. All error reporting is delegated to `@granit/logger` — there is no built-in error UI. The consumer app provides its own fallback via `renderFallback`. **Peer dependencies:** `@granit/logger`, `react ^19` ## Package structure [Section titled “Package structure”](#package-structure) | Package | Role | Depends on | | ------------------------------ | ------------------------------------------------------------------------------------ | --------------------------------------------------- | | `@granit/error-boundary` | `Breadcrumb`, `ErrorContextConfig`, `ErrorContextValue` | — | | `@granit/react-error-boundary` | `GranitErrorBoundary`, `GlobalErrorCapture`, `ErrorContextProvider`, `useBreadcrumb` | `@granit/error-boundary`, `@granit/logger`, `react` | ## Setup [Section titled “Setup”](#setup) * React (recommended) ```tsx import { GranitErrorBoundary, GlobalErrorCapture, ErrorContextProvider } from '@granit/react-error-boundary'; import { createLogger } from '@granit/logger'; const logger = createLogger('app'); function App({ children }) { return ( window.location.pathname, getUserInfo: () => ({ id: currentUser.id }), maxBreadcrumbs: 20, }}> (

Something went wrong

)} > {children}
); } ``` * TypeScript only ```ts import type { Breadcrumb, ErrorContextConfig, ErrorContextValue } from '@granit/error-boundary'; ``` ## TypeScript SDK [Section titled “TypeScript SDK”](#typescript-sdk) ### Types [Section titled “Types”](#types) ```ts interface Breadcrumb { category: string; // e.g. "navigation", "user", "api" message: string; timestamp: string; // ISO 8601 } interface ErrorContextConfig { getRouteInfo?: () => string; getUserInfo?: () => { id: string }; maxBreadcrumbs?: number; // default: 20 (FIFO circular buffer) } interface ErrorContextValue { readonly breadcrumbs: readonly Breadcrumb[]; addBreadcrumb: (category: string, message: string) => void; getRouteInfo: () => string | undefined; getUserInfo: () => { id: string } | undefined; } ``` ## React bindings [Section titled “React bindings”](#react-bindings) ### `ErrorContextProvider` [Section titled “ErrorContextProvider”](#errorcontextprovider) Provides contextual information (route, user, breadcrumbs) that is automatically attached to errors caught by `GranitErrorBoundary` and `GlobalErrorCapture`. ```tsx window.location.pathname, getUserInfo: () => ({ id: user.id }), maxBreadcrumbs: 20, }}> {children} ``` ### `GranitErrorBoundary` [Section titled “GranitErrorBoundary”](#graniterrorboundary) Headless React error boundary. Catches rendering errors, logs them via `@granit/logger`, and delegates UI to the `renderFallback` function. ```ts interface ErrorBoundaryProps { logger: Logger; renderFallback: (error: Error, resetErrorBoundary: () => void) => React.ReactNode; onError?: (error: Error, errorInfo: React.ErrorInfo) => void; children: React.ReactNode; } ``` ### `GlobalErrorCapture` [Section titled “GlobalErrorCapture”](#globalerrorcapture) Invisible component that listens to `window.onerror` and `window.onunhandledrejection`. Deduplicates errors by message within a 1-second window. Logs via `@granit/logger`. ```ts interface GlobalErrorCaptureProps { logger: Logger; onError?: (error: Error) => void; } function GlobalErrorCapture(props: GlobalErrorCaptureProps): null; ``` ### `useErrorContext()` [Section titled “useErrorContext()”](#useerrorcontext) Returns the error context value from the nearest `ErrorContextProvider`. ```ts function useErrorContext(): ErrorContextValue; ``` ### `useBreadcrumb()` [Section titled “useBreadcrumb()”](#usebreadcrumb) Convenience hook for adding breadcrumbs to the error context. ```ts function useBreadcrumb(): { addBreadcrumb: (category: string, message: string) => void; }; ``` ### Error enrichment flow [Section titled “Error enrichment flow”](#error-enrichment-flow) ``` graph LR Breadcrumbs["Breadcrumbs"] --> Context["ErrorContext"] Route["Route info"] --> Context User["User info"] --> Context Context --> Boundary["GranitErrorBoundary"] Context --> Global["GlobalErrorCapture"] Boundary --> Logger["@granit/logger"] Global --> Logger ``` When an error is caught, the error context (breadcrumbs, route, user) is attached to the log entry for debugging. ## Public API summary [Section titled “Public API summary”](#public-api-summary) | Category | Key exports | Package | | ---------------- | ------------------------------------------------------- | ------------------------------ | | Types | `Breadcrumb`, `ErrorContextConfig`, `ErrorContextValue` | `@granit/error-boundary` | | Error boundary | `GranitErrorBoundary` | `@granit/react-error-boundary` | | Global capture | `GlobalErrorCapture` | `@granit/react-error-boundary` | | Context provider | `ErrorContextProvider`, `useErrorContext()` | `@granit/react-error-boundary` | | Breadcrumb hook | `useBreadcrumb()` | `@granit/react-error-boundary` | ## See also [Section titled “See also”](#see-also) * [Logger](./logger/) — Error boundary logs via `@granit/logger` * [Tracing](./tracing/) — Errors are correlated with traces when `TracingProvider` is active # Identity > Identity provider capabilities detection for Keycloak, Entra ID, and other providers `@granit/identity` provides a framework-agnostic function to fetch the active identity provider’s capabilities — which features (session termination, password reset, custom attributes, user creation) the backend provider supports. `@granit/react-identity` wraps this into a React context provider and a `useIdentityCapabilities` hook cached with `staleTime: Infinity`. This lets the UI adapt dynamically: hide the “Terminate session” button when the provider doesn’t support it, disable user creation when the backend can’t handle it. **Peer dependencies:** `axios`, `react ^19`, `@tanstack/react-query ^5` ## Package structure [Section titled “Package structure”](#package-structure) | Package | Role | Depends on | | ------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------- | | `@granit/identity` | `IdentityProviderCapabilities` type, `fetchIdentityCapabilities()` | `axios` | | `@granit/react-identity` | `IdentityProvider`, `useIdentityCapabilities`, `useIdentityConfig` | `@granit/identity`, `@tanstack/react-query`, `axios`, `react` | ## Setup [Section titled “Setup”](#setup) * React (recommended) ```tsx import { IdentityProvider } from '@granit/react-identity'; import { api } from './api-client'; function App() { return ( ); } ``` * TypeScript only ```ts import { fetchIdentityCapabilities } from '@granit/identity'; import type { IdentityProviderCapabilities } from '@granit/identity'; const caps = await fetchIdentityCapabilities(axiosClient, '/identity/users'); if (!caps.supportsUserCreation) { // disable user creation in the UI } ``` ## TypeScript SDK [Section titled “TypeScript SDK”](#typescript-sdk) ### `IdentityProviderCapabilities` [Section titled “IdentityProviderCapabilities”](#identityprovidercapabilities) Describes the capabilities of the active identity provider (Keycloak, Entra ID, etc.). ```ts interface IdentityProviderCapabilities { readonly providerName: string; readonly supportsIndividualSessionTermination: boolean; readonly supportsNativePasswordResetEmail: boolean; readonly supportsGroupHierarchy: boolean; readonly supportsCustomAttributes: boolean; readonly maxCustomAttributes: number; // 0 if not supported readonly supportsCredentialVerification: boolean; readonly supportsUserCreation: boolean; } ``` | Capability | Keycloak | Entra ID | Cognito | | ------------------------------ | --------------- | ------------- | ------------- | | Individual session termination | Yes | No | No | | Native password reset email | Yes | Yes | No | | Group hierarchy | Yes | Yes | No | | Custom attributes | Yes (unlimited) | Yes (limited) | Yes (limited) | | Credential verification | Yes (ROPC) | Varies | Yes | | User creation | Yes | Yes | Yes | ### `fetchIdentityCapabilities(client, basePath)` [Section titled “fetchIdentityCapabilities(client, basePath)”](#fetchidentitycapabilitiesclient-basepath) Fetches capabilities from `GET {basePath}/capabilities`. ```ts function fetchIdentityCapabilities( client: AxiosInstance, basePath: string ): Promise; ``` ## React bindings [Section titled “React bindings”](#react-bindings) ### `IdentityProvider` [Section titled “IdentityProvider”](#identityprovider) Provides identity configuration to descendant components via React context. ```tsx interface IdentityConfig { readonly client: AxiosInstance; readonly basePath?: string; // default: '/identity/users' readonly queryKeyPrefix?: readonly string[]; // default: ['identity'] } interface IdentityProviderProps { readonly config: IdentityConfig; readonly children: ReactNode; } function IdentityProvider(props: IdentityProviderProps): JSX.Element; ``` ### `useIdentityConfig()` [Section titled “useIdentityConfig()”](#useidentityconfig) Returns the `IdentityConfig` from the nearest `IdentityProvider`. Throws if called outside the provider. ```ts function useIdentityConfig(): IdentityConfig; ``` ### `useIdentityCapabilities(options?)` [Section titled “useIdentityCapabilities(options?)”](#useidentitycapabilitiesoptions) Fetches and caches the active identity provider’s capabilities. Cached with `staleTime: Infinity` — capabilities are stable for the backend deployment lifetime. ```ts function useIdentityCapabilities(options?: { enabled?: boolean; }): UseQueryResult; ``` ```tsx function SessionActions({ userId }: { userId: string }) { const { data: caps } = useIdentityCapabilities(); return ( <> {caps?.supportsIndividualSessionTermination && ( )} {caps?.supportsNativePasswordResetEmail && ( )} ); } ``` ### `buildIdentityQueryKey(config, ...segments)` [Section titled “buildIdentityQueryKey(config, ...segments)”](#buildidentityquerykeyconfig-segments) Builds consistent React Query keys for identity operations. ```ts function buildIdentityQueryKey( config: IdentityConfig, ...segments: readonly string[] ): readonly unknown[]; // Examples: buildIdentityQueryKey(config, 'capabilities'); // → ['identity', 'capabilities'] ``` ## Public API summary [Section titled “Public API summary”](#public-api-summary) | Category | Key exports | Package | | ----------------- | ------------------------------------------------------------- | ------------------------ | | Capabilities type | `IdentityProviderCapabilities` | `@granit/identity` | | API function | `fetchIdentityCapabilities()` | `@granit/identity` | | Provider | `IdentityProvider`, `IdentityProviderProps`, `IdentityConfig` | `@granit/react-identity` | | Hooks | `useIdentityCapabilities()`, `useIdentityConfig()` | `@granit/react-identity` | | Utilities | `buildIdentityQueryKey()` | `@granit/react-identity` | ## See also [Section titled “See also”](#see-also) * [Granit.Identity module](/reference/modules/identity/) — .NET identity provider abstractions, Keycloak and Cognito implementations * [Authentication](./authentication/) — Keycloak/Cognito session that provides the JWT for identity operations * [Authorization](./authorization/) — Permission management complements identity capabilities # Localization > i18next integration with backend-driven translations, locale detection cascade, and React language switcher `@granit/localization` provides framework-agnostic i18next configuration for loading translations from the .NET backend — mirroring `Granit.Localization`. It handles locale detection (localStorage → user setting → browser → default), i18next instance creation, and backend response application. `@granit/react-localization` adds React integration with `initReactI18next`, a `useLocale` hook for language switchers, and re-exports `useTranslation`, `Trans`, and `I18nextProvider` from `react-i18next`. **Peer dependencies:** `i18next`, `react-i18next`, `react ^19` ## Package structure [Section titled “Package structure”](#package-structure) | Package | Role | Depends on | | ---------------------------- | ----------------------------------------------------------------- | ------------------------------------------------ | | `@granit/localization` | `createLocalization`, `resolveInitialLocale`, `applyTranslations` | `i18next` | | `@granit/react-localization` | `createReactLocalization`, `useLocale`, re-exports | `@granit/localization`, `react-i18next`, `react` | ## Setup [Section titled “Setup”](#setup) * React (recommended) ```tsx import { createReactLocalization } from '@granit/react-localization'; import { I18nextProvider } from '@granit/react-localization'; import { applyTranslations } from '@granit/localization'; const i18n = createReactLocalization(); // After fetching translations from the backend: const data = await fetch('/api/v1/localization?cultureName=fr'); applyTranslations(i18n, data); function App({ children }) { return ( {children} ); } ``` * TypeScript only ```ts import { createLocalization, resolveInitialLocale, applyTranslations } from '@granit/localization'; import type { LanguageInfo, ApplicationLocalizationDto } from '@granit/localization'; const i18n = createLocalization(); const locale = resolveInitialLocale(); // Fetch translations for resolved locale... ``` ## TypeScript SDK [Section titled “TypeScript SDK”](#typescript-sdk) ### Types [Section titled “Types”](#types) ```ts interface LanguageInfo { cultureName: string; displayName: string; flagIcon?: string; isDefault: boolean; } interface ApplicationLocalizationDto { cultureName: string; resources: Record>; languages: LanguageInfo[]; } interface LocalizationConfig { storageKey?: string; // localStorage key (default: 'locale' → dd:locale) defaultNS?: string; // i18next namespace (default: 'translation') plugins?: Module[]; // i18next plugins (e.g. initReactI18next) } ``` ### Functions [Section titled “Functions”](#functions) #### `createLocalization(config?)` [Section titled “createLocalization(config?)”](#createlocalizationconfig) Creates an isolated i18next instance (not the global singleton). The instance is initialized without a `lng` option — the locale is resolved separately and applied via `applyTranslations`. ```ts function createLocalization(config?: LocalizationConfig): i18n; ``` #### `resolveInitialLocale(languages?, storageKey?, userLocale?)` [Section titled “resolveInitialLocale(languages?, storageKey?, userLocale?)”](#resolveinitiallocalelanguages-storagekey-userlocale) Resolves the initial locale before backend data is available. Detection cascade: 1. `localStorage` (`dd:locale`) — if present, use it 2. `userLocale` from user settings — if provided, use it 3. `navigator.language` — try exact match, then base code 4. `languages` array — use the one with `isDefault: true` 5. Fallback: `'fr'` ```ts function resolveInitialLocale( languages?: LanguageInfo[], storageKey?: string, userLocale?: string | null ): string; ``` #### `applyTranslations(instance, data)` [Section titled “applyTranslations(instance, data)”](#applytranslationsinstance-data) Applies the backend response to the i18next instance. Backend resources are grouped by module (e.g. `{ "Granit": {...}, "Guava": {...} }`) — this function merges them into the single `translation` namespace. ```ts function applyTranslations(instance: i18n, data: ApplicationLocalizationDto): void; ``` ### Constants [Section titled “Constants”](#constants) ```ts const LOCALE_STORAGE_KEY = 'locale'; // prefixed as dd:locale by @granit/storage ``` ## React bindings [Section titled “React bindings”](#react-bindings) ### `createReactLocalization(config?)` [Section titled “createReactLocalization(config?)”](#createreactlocalizationconfig) Convenience wrapper that injects `initReactI18next` as a plugin automatically. ```ts function createReactLocalization( config?: Omit ): i18n; ``` ### `useLocale(options?)` [Section titled “useLocale(options?)”](#uselocaleoptions) Hook for language switchers. Returns the current locale and a setter that persists to localStorage, updates i18next, and optionally notifies the backend. ```ts interface UseLocaleOptions { onLocaleChange?: (locale: string) => void; } function useLocale(options?: UseLocaleOptions): { locale: string; setLocale: (locale: string) => void; }; ``` ### Re-exports [Section titled “Re-exports”](#re-exports) For convenience, the following are re-exported from `react-i18next`: * `I18nextProvider` — Wrap your app to provide the i18next instance * `useTranslation` — Access translations in components * `Trans` — Component for interpolated translations with JSX ## Public API summary [Section titled “Public API summary”](#public-api-summary) | Category | Key exports | Package | | ------------------ | ------------------------------------------------------------------ | ---------------------------- | | Types | `LanguageInfo`, `ApplicationLocalizationDto`, `LocalizationConfig` | `@granit/localization` | | Factory | `createLocalization()` | `@granit/localization` | | Locale resolution | `resolveInitialLocale()`, `LOCALE_STORAGE_KEY` | `@granit/localization` | | Translation loader | `applyTranslations()` | `@granit/localization` | | React factory | `createReactLocalization()` | `@granit/react-localization` | | Locale hook | `useLocale()` | `@granit/react-localization` | | Re-exports | `I18nextProvider`, `useTranslation`, `Trans` | `@granit/react-localization` | ## See also [Section titled “See also”](#see-also) * [Granit.Localization module](/reference/modules/localization/) — .NET i18n with 17 cultures, override store, and source-generated keys * [Settings](./settings/) — `SETTING_NAMES.PREFERRED_CULTURE` stores the user’s language preference * [Reference Data](./reference-data/) — Country labels use the same multilingual pattern # Logger > Configurable logger factory with pluggable transports, OTLP export for log-to-trace correlation `@granit/logger` provides a structured logging system for browser applications with configurable severity levels, named prefixes, child loggers, and a pluggable transport architecture. `@granit/logger-otlp` adds an OTLP HTTP transport that sends logs to an OpenTelemetry collector (Grafana Alloy, Aspire Dashboard) with trace correlation — pairing with `Granit.Observability` on the .NET backend. ## Package structure [Section titled “Package structure”](#package-structure) | Package | Role | Depends on | | --------------------- | -------------------------------------------------------- | ---------------- | | `@granit/logger` | `createLogger()`, `createConsoleTransport()`, `LogLevel` | — (no peer deps) | | `@granit/logger-otlp` | `createOtlpTransport()` for OTLP HTTP log export | `@granit/logger` | ## Setup [Section titled “Setup”](#setup) * Console only (development) ```ts import { createLogger } from '@granit/logger'; const logger = createLogger('MyApp'); logger.info('Application started'); logger.error('Something failed', new Error('connection refused')); ``` No configuration needed. Defaults to `createConsoleTransport()` with colored DevTools output. * Console + OTLP (production) ```ts import { createLogger, createConsoleTransport } from '@granit/logger'; import { createOtlpTransport } from '@granit/logger-otlp'; import { getTraceContext } from '@granit/tracing'; const logger = createLogger('MyApp', { level: 'INFO', transports: [ createConsoleTransport(), createOtlpTransport({ endpoint: '/v1/logs', serviceName: 'guava-front', serviceVersion: '1.0.0', environment: 'production', getTraceContext, // log-to-trace correlation }), ], }); ``` ## TypeScript SDK [Section titled “TypeScript SDK”](#typescript-sdk) ### `createLogger(prefix, options?)` [Section titled “createLogger(prefix, options?)”](#createloggerprefix-options) Creates a logger instance with a named prefix and configurable transports. ```ts interface LoggerOptions { level?: LogLevelName; // default: auto-detected from Vite environment transports?: LogTransport[]; // default: [createConsoleTransport()] } function createLogger(prefix: string, options?: LoggerOptions): Logger; ``` **Level auto-detection:** defaults to `'DEBUG'` in Vite dev mode (`import.meta.env.DEV`), `'WARN'` in production builds. ### Logger interface [Section titled “Logger interface”](#logger-interface) ```ts interface Logger { debug(message: string, context?: LogContext): void; info(message: string, context?: LogContext): void; warn(message: string, context?: LogContext): void; error(message: string, error?: unknown, context?: LogContext): void; child(subPrefix: string): Logger; } type LogContext = Record; ``` Child loggers inherit the parent’s level and transports. Prefixes are concatenated: ```ts const auth = createLogger('Auth'); const token = auth.child('Token'); token.info('Refreshed'); // "[Auth] [Token] Refreshed" ``` ### Log levels [Section titled “Log levels”](#log-levels) ```ts const LogLevel = { DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3, } as const; type LogLevelName = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR'; type LogLevelValue = 0 | 1 | 2 | 3; ``` | Level | Console method | DevTools badge color | | ------- | --------------- | -------------------- | | `DEBUG` | `console.log` | Grey `#71717a` | | `INFO` | `console.info` | Blue `#0ea5e9` | | `WARN` | `console.warn` | Amber `#f59e0b` | | `ERROR` | `console.error` | Red `#ef4444` | ### Transport interface [Section titled “Transport interface”](#transport-interface) ```ts interface LogTransport { send(entry: LogEntry): void; flush?(): Promise; } interface LogEntry { timestamp: number; // Unix ms (Date.now()) level: LogLevelName; prefix: string; message: string; error?: unknown; context?: LogContext; } ``` Any transport with a `flush()` method is automatically registered for a shared `beforeunload` event listener to ensure logs are delivered before the page closes. ### `createConsoleTransport()` [Section titled “createConsoleTransport()”](#createconsoletransport) Built-in transport that outputs colored, prefixed messages to the browser DevTools console. ```ts function createConsoleTransport(): LogTransport; ``` *** ## OTLP transport [Section titled “OTLP transport”](#otlp-transport) `@granit/logger-otlp` sends logs as OTLP JSON to an OpenTelemetry-compatible collector. Logs are batched and flushed periodically for efficiency. ### `createOtlpTransport(options)` [Section titled “createOtlpTransport(options)”](#createotlptransportoptions) ```ts interface OtlpTransportOptions { endpoint: string; // e.g. '/v1/logs' or full URL serviceName: string; // e.g. 'guava-front' serviceVersion?: string; environment?: string; // e.g. 'production' headers?: Record; // additional HTTP headers batchSize?: number; // auto-flush threshold, default: 10 flushInterval?: number; // ms between flushes, default: 5_000 getTraceContext?: () => TraceContext | undefined; // log-to-trace correlation } interface TraceContext { traceId: string; spanId: string; } function createOtlpTransport(options: OtlpTransportOptions): LogTransport; ``` **OTLP mapping:** * Log levels map to OTLP severity numbers: `DEBUG=5`, `INFO=9`, `WARN=13`, `ERROR=17` * Resource attributes: `service.name`, `service.version`, `deployment.environment` * Log attributes: `logger.prefix`, context key-values, and `exception.*` for `Error` objects * Uses `fetch` with `keepalive: true` for reliable delivery on page unload * Self-disables on first network or HTTP error to avoid log spam ### Vite proxy for development [Section titled “Vite proxy for development”](#vite-proxy-for-development) Route OTLP requests to the Aspire Dashboard (or local collector) via Vite’s dev server proxy: vite.config.ts ```ts export default defineConfig({ server: { proxy: { '/v1/logs': 'http://localhost:18889', }, }, }); ``` ### Log-to-trace correlation [Section titled “Log-to-trace correlation”](#log-to-trace-correlation) Pass `getTraceContext` from `@granit/tracing` to correlate logs with distributed traces in Grafana (Loki → Tempo): ```ts import { getTraceContext } from '@granit/tracing'; createOtlpTransport({ endpoint: '/v1/logs', serviceName: 'guava-front', getTraceContext, }); ``` Each log entry includes `traceId` and `spanId` from the active OpenTelemetry span, enabling direct navigation from a log line to its trace in Grafana Tempo. ## Public API summary [Section titled “Public API summary”](#public-api-summary) | Category | Key exports | Package | | --------- | --------------------------------------------------------------- | --------------------- | | Factory | `createLogger()` | `@granit/logger` | | Transport | `createConsoleTransport()`, `LogTransport` | `@granit/logger` | | OTLP | `createOtlpTransport()`, `OtlpTransportOptions`, `TraceContext` | `@granit/logger-otlp` | | Types | `Logger`, `LogEntry`, `LogContext`, `LoggerOptions` | `@granit/logger` | | Constants | `LogLevel`, `LogLevelName`, `LogLevelValue` | `@granit/logger` | ## See also [Section titled “See also”](#see-also) * [Granit.Observability module](/reference/modules/observability/) — .NET Serilog + OpenTelemetry * [Tracing](./tracing/) — OpenTelemetry browser tracing, `getTraceContext()` for correlation * [Error boundary](./error-boundary/) — uses `@granit/logger` for error reporting # Multi-tenancy > Tenant resolution from JWT claims with automatic X-Tenant-Id header injection `@granit/multi-tenancy` defines a framework-agnostic tenant resolver abstraction — a pipeline of ordered resolvers that extract the current tenant from JWT claims, URL patterns, or other sources. `@granit/react-multi-tenancy` provides `TenantProvider` which resolves the tenant, exposes it via context, and automatically wires the `X-Tenant-Id` header on all HTTP requests through `@granit/api-client`. This mirrors the .NET `Granit.MultiTenancy` module: same resolver pipeline pattern, same claim names, same header convention. **Peer dependencies:** `react ^19`, `@granit/api-client` ## Package structure [Section titled “Package structure”](#package-structure) | Package | Role | Depends on | | ----------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | | `@granit/multi-tenancy` | `TenantInfo`, `CurrentTenant`, `TenantResolver`, `resolveTenant()`, `createJwtClaimTenantResolver()` | — | | `@granit/react-multi-tenancy` | `TenantProvider`, `useTenant`, `useKeycloakTenantResolvers` | `@granit/multi-tenancy`, `@granit/api-client`, `react` | ## Dependency graph [Section titled “Dependency graph”](#dependency-graph) ``` graph TD mt["@granit/multi-tenancy"] reactMt["@granit/react-multi-tenancy"] apiClient["@granit/api-client"] reactMt --> mt reactMt --> apiClient ``` ## Setup [Section titled “Setup”](#setup) * React + Keycloak ```tsx import { TenantProvider } from '@granit/react-multi-tenancy'; import { useKeycloakTenantResolvers } from '@granit/react-multi-tenancy'; import { useAuth } from './auth-context'; function AppWithTenant({ children }: { children: React.ReactNode }) { const { tokenParsed } = useAuth(); const resolvers = useKeycloakTenantResolvers({ tokenParsed }); return ( {children} ); } ``` * TypeScript only ```ts import { createJwtClaimTenantResolver, resolveTenant, } from '@granit/multi-tenancy'; import type { TenantResolver, TenantInfo } from '@granit/multi-tenancy'; const jwtResolver = createJwtClaimTenantResolver({ tokenParsedGetter: () => decodedToken, }); const tenant = resolveTenant([jwtResolver]); if (tenant) { console.log(`Tenant: ${tenant.name} (${tenant.id})`); } ``` ## TypeScript SDK [Section titled “TypeScript SDK”](#typescript-sdk) ### `TenantInfo` [Section titled “TenantInfo”](#tenantinfo) ```ts interface TenantInfo { readonly id: string; readonly name?: string; } ``` ### `CurrentTenant` [Section titled “CurrentTenant”](#currenttenant) ```ts interface CurrentTenant { readonly isAvailable: boolean; readonly tenantId: string | undefined; readonly tenantName: string | undefined; } ``` ### `MultiTenancyOptions` [Section titled “MultiTenancyOptions”](#multitenancyoptions) ```ts interface MultiTenancyOptions { readonly isEnabled?: boolean; // default: true readonly tenantIdClaimType?: string; // default: 'tenant_id' readonly tenantIdHeaderName?: string; // default: 'X-Tenant-Id' } ``` ### `DEFAULT_MULTI_TENANCY_OPTIONS` [Section titled “DEFAULT\_MULTI\_TENANCY\_OPTIONS”](#default_multi_tenancy_options) ```ts const DEFAULT_MULTI_TENANCY_OPTIONS: Required = { isEnabled: true, tenantIdClaimType: 'tenant_id', tenantIdHeaderName: 'X-Tenant-Id', } as const; ``` ### `TenantResolver` [Section titled “TenantResolver”](#tenantresolver) Interface for tenant resolution strategies. Mirrors .NET `ITenantResolver`. ```ts interface TenantResolver { readonly order: number; // lower = higher priority readonly name: string; // for logging/debugging resolve(): TenantInfo | null; // null = unable to resolve } ``` ### `resolveTenant(resolvers)` [Section titled “resolveTenant(resolvers)”](#resolvetenantresolvers) Executes resolvers in ascending `order`, returning the first non-null result. Mirrors the .NET `TenantResolverPipeline`. ```ts function resolveTenant(resolvers: readonly TenantResolver[]): TenantInfo | null; ``` ### `createJwtClaimTenantResolver(options)` [Section titled “createJwtClaimTenantResolver(options)”](#createjwtclaimtenantresolveroptions) Factory that creates a resolver extracting tenant ID from JWT claims. ```ts interface JwtClaimTenantResolverOptions { readonly tokenParsedGetter: () => Record | undefined; readonly claimType?: string; // default: 'tenant_id' } function createJwtClaimTenantResolver( options: JwtClaimTenantResolverOptions ): TenantResolver; ``` Returns a resolver with `order: 200` and `name: "JwtClaimTenantResolver"`. Extracts `tenant_id` (or custom claim) and optional `tenant_name` from the decoded JWT. Returns `null` if the token is undefined, the claim is missing, or the claim is empty. ### Resolver priority [Section titled “Resolver priority”](#resolver-priority) | Resolver | Order | Source | | ---------------------- | ----- | -------------------------------- | | Custom (URL/subdomain) | 100 | Developer-defined | | JWT claim | 200 | `createJwtClaimTenantResolver()` | First non-null result wins. The pipeline does not mutate the resolver array. ## React bindings [Section titled “React bindings”](#react-bindings) ### `TenantProvider` [Section titled “TenantProvider”](#tenantprovider) Resolves the tenant from the provided resolvers and exposes it via context. Automatically registers a tenant getter on `@granit/api-client` via `setTenantGetter()`, which adds the `X-Tenant-Id` header to all HTTP requests. ```tsx interface TenantProviderProps { readonly resolvers: readonly TenantResolver[]; readonly options?: MultiTenancyOptions; readonly children: ReactNode; } function TenantProvider(props: TenantProviderProps): JSX.Element; ``` When multi-tenancy is disabled (`options.isEnabled: false`) or no resolver matches, the provider exposes `{ isAvailable: false, tenantId: undefined, tenantName: undefined }`. ### `useTenant()` [Section titled “useTenant()”](#usetenant) Returns the `CurrentTenant` from the nearest `TenantProvider`. Throws if called outside the provider. ```ts function useTenant(): CurrentTenant; ``` ```tsx function TenantBadge() { const { tenantId, tenantName, isAvailable } = useTenant(); if (!isAvailable) return null; return {tenantName ?? tenantId}; } ``` ### `useKeycloakTenantResolvers(options)` [Section titled “useKeycloakTenantResolvers(options)”](#usekeycloaktenantresolversoptions) Creates a memoized array of tenant resolvers wired to a Keycloak token. Pass the result directly to `TenantProvider`. ```ts interface UseKeycloakTenantResolversOptions { readonly tokenParsed: Record | undefined; readonly claimType?: string; // default: 'tenant_id' } function useKeycloakTenantResolvers( options: UseKeycloakTenantResolversOptions ): readonly TenantResolver[]; ``` ### How it flows [Section titled “How it flows”](#how-it-flows) ``` graph LR KC["Keycloak JWT"] --> Resolver["JWT Claim Resolver"] Resolver --> Provider["TenantProvider"] Provider --> Context["useTenant()"] Provider --> Header["X-Tenant-Id header"] Header --> API["@granit/api-client"] API --> Backend[".NET backend"] ``` 1. Keycloak authenticates the user and includes `tenant_id` in the JWT 2. `useKeycloakTenantResolvers` creates a resolver wired to `tokenParsed` 3. `TenantProvider` runs the resolver pipeline and exposes the tenant via context 4. `setTenantGetter()` ensures every HTTP request includes `X-Tenant-Id` 5. The .NET backend receives the header and resolves the tenant via `HeaderTenantResolver` ## Public API summary [Section titled “Public API summary”](#public-api-summary) | Category | Key exports | Package | | -------- | ------------------------------------------------------------------------------------- | ----------------------------- | | Types | `TenantInfo`, `CurrentTenant`, `MultiTenancyOptions`, `DEFAULT_MULTI_TENANCY_OPTIONS` | `@granit/multi-tenancy` | | Resolver | `TenantResolver`, `resolveTenant()`, `createJwtClaimTenantResolver()` | `@granit/multi-tenancy` | | Provider | `TenantProvider`, `TenantProviderProps` | `@granit/react-multi-tenancy` | | Hooks | `useTenant()`, `useKeycloakTenantResolvers()` | `@granit/react-multi-tenancy` | ## See also [Section titled “See also”](#see-also) * [Granit.MultiTenancy module](/reference/modules/multi-tenancy/) — .NET tenant isolation and resolver pipeline * [API client](./api-client/) — `setTenantGetter` is wired automatically by `TenantProvider` * [Authentication](./authentication/) — Keycloak session that provides the JWT with `tenant_id` # Notifications > Real-time notifications with pluggable transports (SignalR, SSE), Web Push, mobile push, activity feed, and user preferences `@granit/notifications` provides a transport-agnostic notification system — types, API functions, and a `NotificationTransport` interface for plugging in real-time channels. `@granit/react-notifications` wraps this into a provider with hooks for the notification inbox, unread count, activity feed, and user preferences. Four transport packages implement the `NotificationTransport` interface: SignalR (WebSocket + LongPolling), SSE (Server-Sent Events), Web Push (VAPID), and mobile push (Capacitor). **Peer dependencies:** `axios`, `react ^19`, `@tanstack/react-query ^5` ## Package structure [Section titled “Package structure”](#package-structure) | Package | Role | Depends on | | ----------------------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------- | | `@granit/notifications` | DTOs, API functions, `NotificationTransport` interface | `axios` | | `@granit/react-notifications` | `NotificationProvider`, inbox/feed/preferences hooks | `@granit/notifications`, `@granit/react-querying`, `react` | | `@granit/notifications-signalr` | `createSignalRTransport()` factory | `@granit/notifications`, `@microsoft/signalr` | | `@granit/notifications-sse` | `createSseTransport()` factory | `@granit/notifications`, `@microsoft/fetch-event-source` | | `@granit/notifications-web-push` | `registerPushSubscription()`, VAPID helpers | `axios` | | `@granit/react-notifications-web-push` | `useWebPush()` hook | `@granit/notifications-web-push`, `react` | | `@granit/notifications-mobile-push` | `registerDeviceToken()`, `unregisterDeviceToken()` | `axios` | | `@granit/react-notifications-mobile-push` | `useMobilePush()` hook | `@granit/notifications-mobile-push`, `@capacitor/push-notifications`, `react` | ## Dependency graph [Section titled “Dependency graph”](#dependency-graph) ``` graph TD core["@granit/notifications"] react["@granit/react-notifications"] signalr["@granit/notifications-signalr"] sse["@granit/notifications-sse"] webPush["@granit/notifications-web-push"] reactWebPush["@granit/react-notifications-web-push"] mobilePush["@granit/notifications-mobile-push"] reactMobilePush["@granit/react-notifications-mobile-push"] react --> core signalr --> core sse --> core reactWebPush --> webPush reactMobilePush --> mobilePush style signalr fill:#e3f2fd,stroke:#1976d2,color:#0d47a1 style sse fill:#e3f2fd,stroke:#1976d2,color:#0d47a1 style webPush fill:#e3f2fd,stroke:#1976d2,color:#0d47a1 style mobilePush fill:#e3f2fd,stroke:#1976d2,color:#0d47a1 ``` ## Setup [Section titled “Setup”](#setup) * React + SignalR ```tsx import { NotificationProvider } from '@granit/react-notifications'; import { createSignalRTransport } from '@granit/notifications-signalr'; import { api } from './api-client'; const transport = createSignalRTransport({ hubUrl: '/hubs/notifications', tokenGetter: () => keycloak.token, }); function App() { return ( ); } ``` * React + SSE ```tsx import { NotificationProvider } from '@granit/react-notifications'; import { createSseTransport } from '@granit/notifications-sse'; const transport = createSseTransport({ streamUrl: '/api/v1/notifications/stream', tokenGetter: () => keycloak.token, }); {children} ``` * TypeScript only ```ts import type { NotificationDto, NotificationTransport } from '@granit/notifications'; import { fetchNotifications, markAsRead } from '@granit/notifications'; ``` ## TypeScript SDK [Section titled “TypeScript SDK”](#typescript-sdk) ### Types [Section titled “Types”](#types) #### `NotificationSeverity` [Section titled “NotificationSeverity”](#notificationseverity) ```ts type NotificationSeverity = 'info' | 'success' | 'warning' | 'error'; ``` #### `NotificationDto` [Section titled “NotificationDto”](#notificationdto) ```ts interface NotificationDto { id: string; title: string; body: string | null; severity: NotificationSeverity; entityType: string | null; entityId: string | null; isRead: boolean; createdAt: string; readAt: string | null; } ``` #### `NotificationPageDto` [Section titled “NotificationPageDto”](#notificationpagedto) ```ts interface NotificationPageDto { items: NotificationDto[]; totalCount: number; nextCursor: string | null; unreadCount: number; } ``` #### `ActivityFeedEntryDto` [Section titled “ActivityFeedEntryDto”](#activityfeedentrydto) ```ts interface ActivityFeedEntryDto { id: string; title: string; body: string | null; severity: NotificationSeverity; createdAt: string; userId: string | null; userDisplayName: string | null; } ``` #### `NotificationChannels` [Section titled “NotificationChannels”](#notificationchannels) ```ts const NotificationChannels = { InApp: 'inApp', Email: 'email', Sms: 'sms', WhatsApp: 'whatsApp', Push: 'push', MobilePush: 'mobilePush', Sse: 'sse', SignalR: 'signalR', Zulip: 'zulip', } as const; ``` #### `NotificationPreferenceDto` [Section titled “NotificationPreferenceDto”](#notificationpreferencedto) ```ts interface NotificationPreferenceDto { notificationType: string; label: string; channels: Record; } ``` #### `ConnectionState` [Section titled “ConnectionState”](#connectionstate) ```ts type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting'; ``` ### Transport interface [Section titled “Transport interface”](#transport-interface) ```ts interface NotificationTransport { connect(): Promise; disconnect(): Promise; readonly state: ConnectionState; onNotification(callback: (notification: NotificationDto) => void): () => void; onStateChange(callback: (state: ConnectionState) => void): () => void; } ``` Any transport implementing this interface can be plugged into `NotificationProvider`. ### API functions [Section titled “API functions”](#api-functions) | Function | Endpoint | Description | | -------------------------------------------------------------------------- | -------------------------------------- | ---------------------- | | `fetchNotifications(client, basePath, params?)` | `GET /notifications` | Paginated inbox | | `markAsRead(client, basePath, id)` | `PATCH /notifications/{id}/read` | Mark single as read | | `markAllAsRead(client, basePath)` | `POST /notifications/read-all` | Mark all as read | | `fetchUnreadCount(client, basePath)` | `GET /notifications/unread-count` | Unread count | | `fetchEntityActivityFeed(client, basePath, entityType, entityId, params?)` | `GET /activity-feed/{type}/{id}` | Entity activity feed | | `fetchPreferences(client, basePath)` | `GET /notification-preferences` | User preference matrix | | `updatePreference(client, basePath, preference)` | `PUT /notification-preferences/{type}` | Update preference | ## Transport implementations [Section titled “Transport implementations”](#transport-implementations) ### SignalR [Section titled “SignalR”](#signalr) ```ts interface SignalRTransportConfig { readonly hubUrl: string; readonly tokenGetter?: () => Promise; } function createSignalRTransport(config: SignalRTransportConfig): NotificationTransport; ``` WebSocket (priority) with LongPolling fallback. Listens to the `ReceiveNotification` hub event. Auto-reconnects with token refresh on each reconnection. ### SSE (Server-Sent Events) [Section titled “SSE (Server-Sent Events)”](#sse-server-sent-events) ```ts interface SseTransportConfig { readonly streamUrl: string; readonly tokenGetter?: () => Promise; readonly heartbeatTypeName?: string; // default: '__heartbeat__' } function createSseTransport(config: SseTransportConfig): NotificationTransport; ``` Uses `@microsoft/fetch-event-source` for auto-reconnection. Heartbeat events are filtered automatically. Connection persists in background tabs (`openWhenHidden: true`). ## React bindings [Section titled “React bindings”](#react-bindings) ### `NotificationProvider` [Section titled “NotificationProvider”](#notificationprovider) ```tsx interface NotificationProviderProps { children: React.ReactNode; config: NotificationConfig; transport?: NotificationTransport; } function NotificationProvider(props: NotificationProviderProps): JSX.Element; ``` ### `useNotificationContext()` [Section titled “useNotificationContext()”](#usenotificationcontext) Returns connection state, last received notification, and unread count from the provider. ### `useNotifications(options?)` [Section titled “useNotifications(options?)”](#usenotificationsoptions) Paginated inbox with mark-as-read support and infinite scroll via `@granit/react-querying`. ```ts interface UseNotificationsReturn { notifications: readonly NotificationDto[]; totalCount: number; loading: boolean; loadingMore: boolean; hasMore: boolean; loadMore: () => void; refresh: () => void; markRead: (id: string) => Promise; markAllRead: () => Promise; } ``` ### `useUnreadCount(options?)` [Section titled “useUnreadCount(options?)”](#useunreadcountoptions) Unread badge count with configurable polling interval (default: 60 seconds). ```ts interface UseUnreadCountReturn { count: number; refresh: () => void; } ``` ### `useEntityActivityFeed(options)` [Section titled “useEntityActivityFeed(options)”](#useentityactivityfeedoptions) Activity feed scoped to a specific entity (e.g. all actions on an invoice). ```ts interface UseEntityActivityFeedReturn { entries: readonly ActivityFeedEntryDto[]; totalCount: number; loading: boolean; hasMore: boolean; loadMore: () => void; refresh: () => void; } ``` ### `useNotificationPreferences()` [Section titled “useNotificationPreferences()”](#usenotificationpreferences) User notification preference matrix (type × channel) with toggle support. ```ts interface UseNotificationPreferencesReturn { preferences: NotificationPreferenceDto[]; loading: boolean; saving: boolean; toggleChannel: (notificationType: string, channel: string, enabled: boolean) => Promise; refresh: () => void; } ``` ## Web Push [Section titled “Web Push”](#web-push) ### TypeScript SDK [Section titled “TypeScript SDK”](#typescript-sdk-1) ```ts // Register a Web Push subscription with the backend async function registerPushSubscription( client: AxiosInstance, basePath: string, subscription: PushSubscriptionJSON ): Promise; // Unregister a subscription async function unregisterPushSubscription( client: AxiosInstance, basePath: string, endpoint: string ): Promise; // Convert URL-safe Base64 VAPID key to Uint8Array for pushManager.subscribe() function urlBase64ToUint8Array(base64String: string): Uint8Array; ``` ### React hook [Section titled “React hook”](#react-hook) ```ts interface WebPushConfig { readonly vapidPublicKey: string; readonly apiClient: AxiosInstance; readonly basePath?: string; readonly serviceWorkerPath?: string; // default: '/sw.js' } interface UseWebPushReturn { readonly isSupported: boolean; readonly permission: NotificationPermission; readonly isSubscribed: boolean; readonly loading: boolean; subscribe: () => Promise; unsubscribe: () => Promise; } function useWebPush(config: WebPushConfig): UseWebPushReturn; ``` ## Mobile Push [Section titled “Mobile Push”](#mobile-push) ### TypeScript SDK [Section titled “TypeScript SDK”](#typescript-sdk-2) ```ts type MobilePlatform = 'android' | 'ios'; interface DeviceTokenDto { readonly token: string; readonly platform: MobilePlatform; readonly deviceId?: string; } async function registerDeviceToken(client, basePath, payload: DeviceTokenDto): Promise; async function unregisterDeviceToken(client, basePath, token: string): Promise; ``` ### React hook [Section titled “React hook”](#react-hook-1) ```ts interface UseMobilePushReturn { readonly isRegistered: boolean; readonly loading: boolean; register: () => Promise; unregister: () => Promise; } function useMobilePush(config: MobilePushConfig): UseMobilePushReturn; ``` Requires `@capacitor/push-notifications`. Handles automatic token refresh — unregisters the old token and registers the new one on device-side token rotation. Push payloads All push channels (Web + Mobile) send wake-up payloads only — no PII in the push payload body. The notification content is fetched via the API after the device wakes up (ISO 27001 compliant). ## Public API summary [Section titled “Public API summary”](#public-api-summary) | Category | Key exports | Package | | ------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | | Core types | `NotificationDto`, `NotificationSeverity`, `ConnectionState`, `NotificationChannels` | `@granit/notifications` | | Transport | `NotificationTransport`, `NotificationConfig` | `@granit/notifications` | | API functions | `fetchNotifications()`, `markAsRead()`, `markAllAsRead()`, `fetchUnreadCount()` | `@granit/notifications` | | Activity feed | `fetchEntityActivityFeed()`, `ActivityFeedEntryDto` | `@granit/notifications` | | Preferences | `fetchPreferences()`, `updatePreference()`, `NotificationPreferenceDto` | `@granit/notifications` | | SignalR | `createSignalRTransport()` | `@granit/notifications-signalr` | | SSE | `createSseTransport()` | `@granit/notifications-sse` | | Provider | `NotificationProvider`, `useNotificationContext()` | `@granit/react-notifications` | | Inbox hooks | `useNotifications()`, `useUnreadCount()` | `@granit/react-notifications` | | Feed hooks | `useEntityActivityFeed()`, `useNotificationPreferences()` | `@granit/react-notifications` | | Web Push | `useWebPush()`, `registerPushSubscription()`, `urlBase64ToUint8Array()` | `@granit/react-notifications-web-push`, `@granit/notifications-web-push` | | Mobile Push | `useMobilePush()`, `registerDeviceToken()` | `@granit/react-notifications-mobile-push`, `@granit/notifications-mobile-push` | ## See also [Section titled “See also”](#see-also) * [Granit.Notifications module](/reference/modules/notifications/) — .NET notification engine (fan-out, delivery tracking, 6 channels) * [Querying](./querying/) — Pagination primitives used by the notification inbox # Querying > Headless data grid with filtering, sorting, pagination, grouping, saved views, and SmartFilterBar `@granit/querying` is the largest frontend package — a framework-agnostic, headless data grid system that mirrors `Granit.Querying` on the .NET backend. It provides types for filters, sorts, pagination (offset and cursor), grouping, saved views, query serialization, and a SmartFilterBar suggestion engine. `@granit/react-querying` wraps everything into React hooks with TanStack Query integration: `useQueryEndpoint` (full query state machine), `usePagination`, `useInfiniteScroll`, `useSavedViews`, `useSmartFilter`, and `useQueryMeta`. **Peer dependencies:** `axios`, `@granit/utils`, `react ^19`, `@tanstack/react-query ^5` ## Package structure [Section titled “Package structure”](#package-structure) | Package | Role | Depends on | | ------------------------ | ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------- | | `@granit/querying` | Filter/sort/pagination types, API functions, query serialization, operator utilities | `axios`, `@granit/utils` | | `@granit/react-querying` | `QueryProvider`, `useQueryEndpoint`, `usePagination`, `useInfiniteScroll`, `useSmartFilter`, `useSavedViews` | `@granit/querying`, `@tanstack/react-query`, `react` | ## Setup [Section titled “Setup”](#setup) * React (recommended) ```tsx import { QueryProvider } from '@granit/react-querying'; import { useQueryEndpoint, useQueryMeta } from '@granit/react-querying'; import { api } from './api-client'; function PatientList() { return ( ); } ``` * TypeScript only ```ts import { fetchPage, fetchQueryMeta, serializeQueryParams } from '@granit/querying'; import type { QueryParams, PagedResult, FilterEntry } from '@granit/querying'; ``` ## TypeScript SDK [Section titled “TypeScript SDK”](#typescript-sdk) ### Query parameters [Section titled “Query parameters”](#query-parameters) ```ts interface QueryParams { readonly page?: number; readonly pageSize?: number; readonly cursor?: string; // opaque cursor for keyset pagination readonly search?: string; // global full-text search readonly filters?: readonly FilterEntry[]; readonly sort?: readonly SortEntry[]; readonly presets?: Readonly>; readonly quickFilters?: readonly string[]; readonly groupBy?: string; } ``` ### Filter system [Section titled “Filter system”](#filter-system) ```ts type FilterOperator = | 'Eq' | 'Contains' | 'StartsWith' | 'EndsWith' | 'Gt' | 'Gte' | 'Lt' | 'Lte' | 'In' | 'Between'; interface FilterEntry { readonly field: string; readonly operator: FilterOperator; readonly value: string; // comma-separated for In/Between } interface SortEntry { readonly field: string; readonly direction: 'asc' | 'desc'; } ``` **Operator availability by CLR type:** | Type | Operators | | ---------------------------- | -------------------------------------- | | `String` | Eq, Contains, StartsWith, EndsWith, In | | `Int32`, `Decimal`, `Double` | Eq, Gt, Gte, Lt, Lte, In, Between | | `DateTime`, `DateOnly` | Eq, Gt, Gte, Lt, Lte, Between | | `Boolean` | Eq | | `Guid`, Enums | Eq, In | ### Result types [Section titled “Result types”](#result-types) ```ts interface PagedResult { readonly items: readonly T[]; readonly totalCount: number; readonly nextCursor?: string; } interface GroupedResult { readonly groups: readonly GroupEntry[]; readonly totalCount: number; } interface GroupEntry { readonly field: string; readonly value: unknown; readonly label: string; readonly count: number; readonly aggregates?: Readonly>; readonly items?: readonly T[]; } ``` ### Query metadata [Section titled “Query metadata”](#query-metadata) ```ts interface QueryMetadata { readonly columns: readonly ColumnDefinition[]; readonly filterableFields: readonly FilterableField[]; readonly sortableFields: readonly SortableField[]; readonly presetFilterGroups: readonly FilterGroupMeta[]; readonly quickFilters: readonly QuickFilterMeta[]; readonly dateFilters: readonly DateFilterMeta[]; readonly groupByFields: readonly GroupByField[]; readonly pagination: PaginationMeta; readonly defaultSort?: string; } interface ColumnDefinition { readonly name: string; readonly label: string; readonly type: string; readonly order: number; readonly isSortable: boolean; readonly isFilterable: boolean; readonly isVisible: boolean; readonly format?: string; } interface FilterableField { readonly name: string; readonly type: string; readonly operators: readonly FilterOperator[]; readonly enumValues?: readonly string[]; } ``` ### Saved views [Section titled “Saved views”](#saved-views) ```ts interface SavedViewSummary { readonly id: string; readonly name: string; readonly isShared: boolean; readonly isDefault: boolean; } interface CreateSavedViewRequest { readonly name: string; readonly isShared: boolean; readonly isDefault: boolean; readonly filterJson?: string; readonly sortJson?: string; readonly groupByJson?: string; readonly visibleColumnsJson?: string; } ``` ### SmartFilter types [Section titled “SmartFilter types”](#smartfilter-types) ```ts type SmartFilterPhase = 'idle' | 'selectField' | 'selectOperator' | 'enterValue'; interface FilterToken { readonly id: string; readonly type: 'filter' | 'preset' | 'quickFilter' | 'search'; readonly label: string; readonly field?: string; readonly operator?: FilterOperator; readonly value?: string; } interface FilterSuggestion { readonly id: string; readonly type: 'filter' | 'preset' | 'quickFilter' | 'search'; readonly label: string; readonly description?: string; readonly field?: string; readonly operators?: readonly FilterOperator[]; readonly values?: readonly { value: string; label: string }[]; } ``` ### API functions [Section titled “API functions”](#api-functions) ```ts function fetchPage(client, basePath, params: QueryParams): Promise>; function fetchGrouped(client, basePath, params: QueryParams): Promise>; function fetchQueryMeta(client, basePath): Promise; // Saved views CRUD function fetchSavedViews(client, basePath): Promise; function createSavedView(client, basePath, request): Promise; function updateSavedView(client, basePath, id, request): Promise; function deleteSavedView(client, basePath, id): Promise; function setDefaultSavedView(client, basePath, id): Promise; ``` ### Query serialization [Section titled “Query serialization”](#query-serialization) Bookmarkable URLs — serialize query state to URL search params and back. ```ts function serializeQueryParams(params: QueryParams): string; function parseQueryParams(search: string): QueryParams; // Example: serializeQueryParams({ page: 2, pageSize: 20, search: 'dupont', filters: [{ field: 'Status', operator: 'Eq', value: 'Active' }], sort: [{ field: 'CreatedAt', direction: 'desc' }], }); // → "page=2&pageSize=20&search=dupont&filter[Status.Eq]=Active&sort=-CreatedAt" ``` ### Operator utilities [Section titled “Operator utilities”](#operator-utilities) ```ts const STRING_OPERATORS: readonly FilterOperator[]; const NUMBER_OPERATORS: readonly FilterOperator[]; const DATE_OPERATORS: readonly FilterOperator[]; const BOOLEAN_OPERATORS: readonly FilterOperator[]; const ENUM_OPERATORS: readonly FilterOperator[]; const OPERATOR_LABELS: Record; function inferOperators(clrType: string): readonly FilterOperator[]; ``` ## React bindings [Section titled “React bindings”](#react-bindings) ### `QueryProvider` [Section titled “QueryProvider”](#queryprovider) ```tsx {children} ``` ### `useQueryEndpoint(options?)` [Section titled “useQueryEndpoint\(options?)”](#usequeryendpointtoptions) Main hook — manages the full query state machine (search, filter, sort, pagination, grouping) via `useReducer` (14 actions) with TanStack Query for data fetching. ```ts interface UseQueryEndpointReturn { readonly params: QueryParams; readonly query: UseQueryResult>; readonly groupedQuery: UseQueryResult>; readonly isGrouped: boolean; // Dispatchers readonly setPage: (page: number) => void; readonly setPageSize: (pageSize: number) => void; readonly setSearch: (search: string) => void; readonly setFilters: (filters: readonly FilterEntry[]) => void; readonly addFilter: (filter: FilterEntry) => void; readonly removeFilter: (field: string, operator?: string) => void; readonly setSort: (sort: readonly SortEntry[]) => void; readonly toggleSort: (field: string) => void; // none → asc → desc → none readonly setPresets: (group: string, names: readonly string[]) => void; readonly toggleQuickFilter: (name: string) => void; readonly setGroupBy: (groupBy: string | undefined) => void; readonly setParams: (params: QueryParams) => void; readonly reset: () => void; } ``` Filter, search, preset, and quick-filter changes automatically reset to page 1. ### `usePagination(options)` [Section titled “usePagination\(options)”](#usepaginationtoptions) Classic offset-based pagination with page navigation. ```ts interface UsePaginationReturn { readonly items: readonly T[]; readonly totalCount: number; readonly page: number; readonly totalPages: number; readonly hasPreviousPage: boolean; readonly hasNextPage: boolean; readonly goToPage: (page: number) => void; readonly nextPage: () => void; readonly previousPage: () => void; readonly refresh: () => void; readonly loading: boolean; } ``` ### `useInfiniteScroll(options)` [Section titled “useInfiniteScroll\(options)”](#useinfinitescrolltoptions) Load-more pagination — accumulates items from successive pages. ```ts interface UseInfiniteScrollReturn { readonly items: readonly T[]; readonly totalCount: number; readonly hasMore: boolean; readonly loadMore: () => void; readonly refresh: () => void; readonly loading: boolean; readonly loadingMore: boolean; } ``` ### `useQueryMeta()` [Section titled “useQueryMeta()”](#usequerymeta) Fetches and caches query metadata (`staleTime: Infinity` — stable per deployment). ```ts function useQueryMeta(): UseQueryResult; ``` ### `useSavedViews()` [Section titled “useSavedViews()”](#usesavedviews) Full CRUD for saved views with cache invalidation. ```ts interface UseSavedViewsReturn { readonly views: UseQueryResult; readonly create: UseMutationResult; readonly update: UseMutationResult<...>; readonly remove: UseMutationResult; readonly setDefault: UseMutationResult; } ``` ### `useSmartFilter(options?)` [Section titled “useSmartFilter(options?)”](#usesmartfilteroptions) State machine for the SmartFilterBar omnibox. Manages the flow: field → operator → value, generates contextual suggestions, and outputs `FilterEntry[]` ready for `useQueryEndpoint`. ```ts interface UseSmartFilterReturn { readonly phase: SmartFilterPhase; readonly tokens: readonly FilterToken[]; readonly suggestions: readonly FilterSuggestion[]; // Extracted from tokens (ready for useQueryEndpoint) readonly filters: readonly FilterEntry[]; readonly search: string | undefined; readonly presets: Readonly>; readonly quickFilters: readonly string[]; // Actions readonly setInput: (value: string) => void; readonly selectField: (field: string) => void; readonly selectOperator: (operator: FilterOperator) => void; readonly confirmValue: (value: string) => void; readonly removeToken: (id: string) => void; readonly clearAll: () => void; readonly cancel: () => void; } ``` ## Public API summary [Section titled “Public API summary”](#public-api-summary) | Category | Key exports | Package | | ---------------- | ------------------------------------------------------------------------------------------------- | ------------------------ | | Query types | `QueryParams`, `FilterEntry`, `SortEntry`, `FilterOperator`, `PagedResult`, `GroupedResult` | `@granit/querying` | | Metadata | `QueryMetadata`, `ColumnDefinition`, `FilterableField`, `SortableField` | `@granit/querying` | | Saved views | `SavedViewSummary`, `CreateSavedViewRequest`, `UpdateSavedViewRequest` | `@granit/querying` | | SmartFilter | `FilterToken`, `FilterSuggestion`, `SmartFilterPhase` | `@granit/querying` | | API functions | `fetchPage()`, `fetchGrouped()`, `fetchQueryMeta()`, saved views CRUD | `@granit/querying` | | Serialization | `serializeQueryParams()`, `parseQueryParams()` | `@granit/querying` | | Operators | `inferOperators()`, `STRING_OPERATORS`, `OPERATOR_LABELS` | `@granit/querying` | | Provider | `QueryProvider`, `useQueryConfig()`, `buildQueryKey()` | `@granit/react-querying` | | Query hook | `useQueryEndpoint()` | `@granit/react-querying` | | Pagination | `usePagination()`, `useInfiniteScroll()` | `@granit/react-querying` | | Metadata hook | `useQueryMeta()` | `@granit/react-querying` | | Saved views hook | `useSavedViews()` | `@granit/react-querying` | | Smart filter | `useSmartFilter()` | `@granit/react-querying` | ## See also [Section titled “See also”](#see-also) * [Granit.Querying module](/reference/modules/persistence/) — .NET whitelist-first querying with expression trees * [Notifications](./notifications/) — Uses `useInfiniteScroll` from this package for the notification inbox * [Data Exchange](./data-exchange/) — Uses `@granit/utils` for shared formatting # Reference Data > Country reference data with i18n labels, filtering, pagination, and admin CRUD mutations `@granit/reference-data` provides framework-agnostic types for reference data entities — mirroring `Granit.ReferenceData` on the .NET backend. Currently covers ISO 3166-1 countries with multilingual labels. `@granit/react-reference-data` wraps these into TanStack Query hooks with separate read and admin endpoints for query/mutation operations. **Peer dependencies:** `axios`, `react ^19`, `@tanstack/react-query ^5` ## Package structure [Section titled “Package structure”](#package-structure) | Package | Role | Depends on | | ------------------------------ | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | | `@granit/reference-data` | `Country`, `CountriesListParams` | — | | `@granit/react-reference-data` | `useCountries`, `useCountry`, `useCreateCountry`, `useUpdateCountry`, `useDeactivateCountry`, `useReactivateCountry` | `@granit/reference-data`, `@tanstack/react-query`, `axios`, `react` | ## Setup [Section titled “Setup”](#setup) * React (recommended) ```tsx import { useCountries, useCountry } from '@granit/react-reference-data'; import { api } from './api-client'; function CountrySelect() { const { data: countries, isLoading } = useCountries({ client: api, params: { isActive: true }, }); // Render country options... } ``` * TypeScript only ```ts import type { Country, CountriesListParams } from '@granit/reference-data'; ``` ## TypeScript SDK [Section titled “TypeScript SDK”](#typescript-sdk) ### Types [Section titled “Types”](#types) ```ts interface Country { readonly code: string; // ISO 3166-1 alpha-2 readonly alpha3: string; // ISO 3166-1 alpha-3 readonly numericCode: string; // ISO 3166-1 numeric readonly labelEn: string; readonly labelFr: string; readonly labelNl: string; readonly labelDe: string; readonly officialName: string; readonly nativeName: string; readonly region: string; readonly subRegion: string; readonly phoneCode: string; readonly sortOrder: number; readonly isActive: boolean; readonly validFrom: string | null; readonly validTo: string | null; readonly createdAt: string; readonly updatedAt: string; } interface CountriesListParams { readonly search?: string; readonly page?: number; readonly pageSize?: number; readonly sortBy?: string; readonly desc?: boolean; readonly region?: string; readonly isActive?: boolean; } ``` ## React bindings [Section titled “React bindings”](#react-bindings) ### Hook options [Section titled “Hook options”](#hook-options) ```ts interface ReferenceDataHookOptions { client: AxiosInstance; basePath?: string; // default: '/api/v1/reference-data/countries' } interface ReferenceDataMutationOptions { client: AxiosInstance; basePath?: string; // default: '/api/v1/admin/reference-data/countries' } ``` ### Query hooks [Section titled “Query hooks”](#query-hooks) #### `useCountries(options)` [Section titled “useCountries(options)”](#usecountriesoptions) Fetches countries with optional filtering, search, pagination, and sorting. ```ts function useCountries( options: ReferenceDataHookOptions & { params?: CountriesListParams; enabled?: boolean } ): UseQueryResult; ``` #### `useCountry(code, options)` [Section titled “useCountry(code, options)”](#usecountrycode-options) Fetches a single country by ISO 3166-1 alpha-2 code. ```ts function useCountry( code: string, options: ReferenceDataHookOptions & { enabled?: boolean } ): UseQueryResult; ``` ### Mutation hooks [Section titled “Mutation hooks”](#mutation-hooks) All mutations invalidate the country list and detail queries on success. #### `useCreateCountry(options)` [Section titled “useCreateCountry(options)”](#usecreatecountryoptions) ```ts type CreateCountryPayload = Omit; function useCreateCountry( options: ReferenceDataMutationOptions ): UseMutationResult; ``` #### `useUpdateCountry(options)` [Section titled “useUpdateCountry(options)”](#useupdatecountryoptions) ```ts interface UpdateCountryPayload { code: string; data: Partial>; } function useUpdateCountry( options: ReferenceDataMutationOptions ): UseMutationResult; ``` #### `useDeactivateCountry(options)` [Section titled “useDeactivateCountry(options)”](#usedeactivatecountryoptions) Soft-deletes a country by code. ```ts function useDeactivateCountry( options: ReferenceDataMutationOptions ): UseMutationResult; ``` #### `useReactivateCountry(options)` [Section titled “useReactivateCountry(options)”](#usereactivatecountryoptions) Reactivates a previously deactivated country. ```ts function useReactivateCountry( options: ReferenceDataMutationOptions ): UseMutationResult; ``` ### Query keys [Section titled “Query keys”](#query-keys) ```ts const countryKeys = { all: ['reference-data', 'countries'] as const, list: (params?: CountriesListParams) => [...countryKeys.all, 'list', params] as const, detail: (code: string) => [...countryKeys.all, 'detail', code] as const, }; ``` ## Public API summary [Section titled “Public API summary”](#public-api-summary) | Category | Key exports | Package | | -------------- | ---------------------------------------------------------------------------------------------- | ------------------------------ | | Types | `Country`, `CountriesListParams` | `@granit/reference-data` | | Query hooks | `useCountries()`, `useCountry()` | `@granit/react-reference-data` | | Mutation hooks | `useCreateCountry()`, `useUpdateCountry()`, `useDeactivateCountry()`, `useReactivateCountry()` | `@granit/react-reference-data` | | Cache keys | `countryKeys` | `@granit/react-reference-data` | ## See also [Section titled “See also”](#see-also) * [Granit.ReferenceData module](/reference/modules/localization/) — .NET reference data with i18n labels and CRUD endpoints * [Localization](./localization/) — Country labels use the same multilingual pattern # Settings > Cascaded application settings with scoped read/write, React Query integration, and automatic cache invalidation `@granit/settings` provides framework-agnostic types and API functions for application settings — mirroring `Granit.Settings` on the .NET backend. Settings follow a cascade: User → Tenant → Global → Config → Default. Deleting a user-level setting makes the tenant or global value take over. `@granit/react-settings` wraps these into a provider and TanStack Query hooks with automatic cache invalidation on mutations. **Peer dependencies:** `axios`, `react ^19`, `@tanstack/react-query ^5` ## Package structure [Section titled “Package structure”](#package-structure) | Package | Role | Depends on | | ------------------------ | --------------------------------------------------------------------------------------- | ---------------------------------------------------- | | `@granit/settings` | DTOs, `SettingScope`, API functions, `SETTING_NAMES` | `axios` | | `@granit/react-settings` | `SettingsProvider`, `useSetting`, `useSettings`, `useUpdateSetting`, `useDeleteSetting` | `@granit/settings`, `@tanstack/react-query`, `react` | ## Setup [Section titled “Setup”](#setup) * React (recommended) ```tsx import { SettingsProvider } from '@granit/react-settings'; import { api } from './api-client'; function App({ children }) { return ( {children} ); } ``` * TypeScript only ```ts import { fetchSettings, updateSetting, deleteSetting } from '@granit/settings'; import type { SettingsMap, SettingScope } from '@granit/settings'; ``` ## TypeScript SDK [Section titled “TypeScript SDK”](#typescript-sdk) ### Types [Section titled “Types”](#types) ```ts type SettingScope = 'user' | 'global' | 'tenant'; interface SettingValueResponse { name: string; value: string | null; } type SettingsMap = Record; interface UpdateSettingValueRequest { value: string | null; } ``` ### Constants [Section titled “Constants”](#constants) ```ts const SETTING_NAMES = { PREFERRED_CULTURE: 'Granit.Localization.PreferredCulture', PREFERRED_TIMEZONE: 'Granit.Timing.PreferredTimezone', } as const; ``` ### API functions [Section titled “API functions”](#api-functions) | Function | Endpoint | Description | | ------------------------------------------------------- | --------------------------------- | ----------------------------------- | | `fetchSettings(client, basePath, scope)` | `GET /settings/{scope}` | All visible settings for a scope | | `fetchSetting(client, basePath, scope, name)` | `GET /settings/{scope}/{name}` | Single setting by name | | `updateSetting(client, basePath, scope, name, request)` | `PUT /settings/{scope}/{name}` | Create or update a setting | | `deleteSetting(client, basePath, scope, name)` | `DELETE /settings/{scope}/{name}` | Delete (reset) — cascade takes over | ## React bindings [Section titled “React bindings”](#react-bindings) ### `SettingsProvider` [Section titled “SettingsProvider”](#settingsprovider) ```tsx interface SettingsProviderProps { readonly config: { readonly client: AxiosInstance; readonly basePath?: string; readonly queryKeyPrefix?: readonly string[]; }; readonly children: ReactNode; } {children} ``` ### `useSettings(scope, options?)` [Section titled “useSettings(scope, options?)”](#usesettingsscope-options) Fetches all visible settings for a scope. ```ts function useSettings( scope: SettingScope, options?: { enabled?: boolean } ): UseQueryResult; ``` ### `useSetting(scope, name, options?)` [Section titled “useSetting(scope, name, options?)”](#usesettingscope-name-options) Fetches a single setting by name. ```ts function useSetting( scope: SettingScope, name: string, options?: { enabled?: boolean } ): UseQueryResult; ``` ### `useUpdateSetting(scope)` [Section titled “useUpdateSetting(scope)”](#useupdatesettingscope) Creates or updates a setting value. Invalidates scope and individual setting queries on success. ```ts interface UseUpdateSettingReturn { readonly update: (name: string, value: string | null) => void; readonly updateAsync: (name: string, value: string | null) => Promise; readonly isPending: boolean; readonly error: Error | null; } ``` ### `useDeleteSetting(scope)` [Section titled “useDeleteSetting(scope)”](#usedeletesettingscope) Deletes a setting so the cascade takes over. Invalidates queries on success. ```ts interface UseDeleteSettingReturn { readonly remove: (name: string) => void; readonly removeAsync: (name: string) => Promise; readonly isPending: boolean; readonly error: Error | null; } ``` ### Setting cascade [Section titled “Setting cascade”](#setting-cascade) ``` graph LR User["User"] --> Tenant["Tenant"] Tenant --> Global["Global"] Global --> Config["Config"] Config --> Default["Default"] ``` When a setting is read, the first non-null value in the cascade wins. Deleting a user-level override makes the tenant or global value visible again. ## Public API summary [Section titled “Public API summary”](#public-api-summary) | Category | Key exports | Package | | -------------- | ---------------------------------------------------------------------------------- | ------------------------ | | Types | `SettingScope`, `SettingValueResponse`, `SettingsMap`, `UpdateSettingValueRequest` | `@granit/settings` | | Constants | `SETTING_NAMES` | `@granit/settings` | | API functions | `fetchSettings()`, `fetchSetting()`, `updateSetting()`, `deleteSetting()` | `@granit/settings` | | Provider | `SettingsProvider`, `useSettingsConfig()`, `buildSettingsQueryKey()` | `@granit/react-settings` | | Query hooks | `useSettings()`, `useSetting()` | `@granit/react-settings` | | Mutation hooks | `useUpdateSetting()`, `useDeleteSetting()` | `@granit/react-settings` | ## See also [Section titled “See also”](#see-also) * [Granit.Settings module](/reference/modules/settings-features/) — .NET cascaded settings with EF Core store * [Localization](./localization/) — `SETTING_NAMES.PREFERRED_CULTURE` stores the user’s preferred language # Storage > Typed localStorage/sessionStorage with automatic key prefixing, cross-tab sync, and React hook `@granit/storage` provides a typed storage factory for `localStorage` and `sessionStorage` with automatic `dd:` key prefixing and custom serialization. `@granit/react-storage` wraps this into a `useStorage` hook that synchronizes component state with the browser storage via `useSyncExternalStore`, including cross-tab updates. **Peer dependencies:** `react ^19` ## Package structure [Section titled “Package structure”](#package-structure) | Package | Role | Depends on | | ----------------------- | ------------------------------------------------- | -------------------------- | | `@granit/storage` | `createStorage`, `TypedStorage`, `StorageOptions` | — | | `@granit/react-storage` | `useStorage` hook | `@granit/storage`, `react` | ## Setup [Section titled “Setup”](#setup) * React (recommended) ```tsx import { useStorage } from '@granit/react-storage'; function Sidebar() { const [open, setOpen] = useStorage('sidebar-open', false); return ; } ``` * TypeScript only ```ts import { createStorage } from '@granit/storage'; const themeStorage = createStorage<'light' | 'dark'>('theme'); themeStorage.set('dark'); // writes to localStorage key "dd:theme" themeStorage.get(); // returns 'dark' themeStorage.remove(); // removes "dd:theme" ``` ## TypeScript SDK [Section titled “TypeScript SDK”](#typescript-sdk) ### Types [Section titled “Types”](#types) ```ts interface StorageOptions { serialize?: (value: T) => string; // default: JSON.stringify deserialize?: (raw: string) => T; // default: JSON.parse storage?: 'local' | 'session'; // default: 'local' } interface TypedStorage { get(): T | null; set(value: T): void; remove(): void; readonly key: string; // prefixed key (e.g. "dd:theme") } ``` ### `createStorage(key, options?)` [Section titled “createStorage\(key, options?)”](#createstoragetkey-options) Creates a typed storage accessor. All keys are automatically prefixed with `dd:` to avoid collisions with third-party libraries. ```ts function createStorage(key: string, options?: StorageOptions): TypedStorage; ``` ## React bindings [Section titled “React bindings”](#react-bindings) ### `useStorage(key, defaultValue, options?)` [Section titled “useStorage\(key, defaultValue, options?)”](#usestoragetkey-defaultvalue-options) React hook that synchronizes component state with browser storage. ```ts function useStorage( key: string, defaultValue: T, options?: StorageOptions ): [T, (value: T) => void]; ``` * Uses `useSyncExternalStore` for tear-free reads * Cross-tab synchronization via the native `storage` event * Maintains referential stability through internal caching * Keys are automatically prefixed with `dd:` ## Key prefixing [Section titled “Key prefixing”](#key-prefixing) All keys are prefixed with `dd:` (Digital Dynamics) to avoid collisions: | Input key | Actual storage key | | -------------- | ------------------ | | `theme` | `dd:theme` | | `sidebar-open` | `dd:sidebar-open` | | `locale` | `dd:locale` | ## Public API summary [Section titled “Public API summary”](#public-api-summary) | Category | Key exports | Package | | ---------- | -------------------------------------- | ----------------------- | | Types | `TypedStorage`, `StorageOptions` | `@granit/storage` | | Factory | `createStorage()` | `@granit/storage` | | React hook | `useStorage()` | `@granit/react-storage` | ## See also [Section titled “See also”](#see-also) * [Granit.BlobStorage module](/reference/modules/blob-storage/) — .NET server-side blob storage (S3-compatible) * [Localization](./localization/) — Uses `dd:locale` via this storage system * [Settings](./settings/) — Server-side cascaded settings complement client-side storage # Templating > Template CRUD, lifecycle management, preview rendering, variables, categories, and revision history `@granit/templating` is a unified package (no TS/React split) that provides template management — mirroring `Granit.Templating` and `Granit.DocumentGeneration` on the .NET backend. It covers CRUD operations, Draft→Published→Archived lifecycle, HTML/PDF/Excel preview rendering, variable discovery, categories, and full revision history. Built on TanStack Query — all hooks return `UseQueryResult` or `UseMutationResult` with automatic cache invalidation. **Peer dependencies:** `axios`, `react ^19`, `@tanstack/react-query ^5` ## Package structure [Section titled “Package structure”](#package-structure) | Package | Role | Depends on | | -------------------- | -------------------------------------------------------------------------------- | ----------------------------------------- | | `@granit/templating` | DTOs, lifecycle enums, API functions, `TemplatingProvider`, query/mutation hooks | `axios`, `@tanstack/react-query`, `react` | ## Setup [Section titled “Setup”](#setup) * React (recommended) ```tsx import { TemplatingProvider } from '@granit/templating'; import { api } from './api-client'; function App({ children }) { return ( {children} ); } ``` * TypeScript only ```ts import { getTemplates, saveDraft, publishTemplate, previewTemplate } from '@granit/templating'; import type { TemplateDetail, SaveTemplateRequest } from '@granit/templating'; ``` ## Enums [Section titled “Enums”](#enums) ```ts const TemplateLifecycleStatus = { Draft: 0, PendingReview: 1, Published: 2, Archived: 3, } as const; type TemplateLifecycleStatusValue = (typeof TemplateLifecycleStatus)[keyof typeof TemplateLifecycleStatus]; const DocumentFormat = { Html: 0, Pdf: 1, Excel: 2, } as const; type DocumentFormatValue = (typeof DocumentFormat)[keyof typeof DocumentFormat]; ``` ## Types [Section titled “Types”](#types) ### Template identity [Section titled “Template identity”](#template-identity) ```ts interface TemplateKey { readonly name: string; readonly culture?: string; } interface TemplateListItem { readonly name: string; readonly culture?: string; readonly category?: string; readonly status: TemplateLifecycleStatusValue; readonly mimeType: string; readonly lastModifiedAt: string; readonly lastModifiedBy: string; readonly hasPublishedVersion: boolean; } interface TemplateDetail { readonly name: string; readonly culture?: string; readonly category?: string; readonly draft?: TemplateRevision; readonly published?: TemplateRevision; } ``` ### Revisions [Section titled “Revisions”](#revisions) ```ts interface TemplateRevision { readonly revisionId: string; readonly content: string; readonly mimeType: string; readonly status: TemplateLifecycleStatusValue; readonly createdAt: string; readonly createdBy: string; readonly publishedAt?: string; readonly publishedBy?: string; } type TemplateRevisionSummary = Omit & { readonly contentLength: number; }; interface TemplateHistory { readonly revisions: readonly TemplateRevisionSummary[]; readonly totalCount: number; readonly page: number; readonly pageSize: number; } ``` ### Mutations [Section titled “Mutations”](#mutations) ```ts interface SaveTemplateRequest { readonly name: string; readonly culture?: string; readonly content: string; readonly mimeType?: string; readonly category?: string; } interface TemplateListParams { readonly page?: number; readonly pageSize?: number; readonly search?: string; readonly status?: TemplateLifecycleStatusValue; readonly category?: string; readonly culture?: string; } ``` ### Lifecycle [Section titled “Lifecycle”](#lifecycle) ```ts interface TemplateLifecycleInfo { readonly name: string; readonly culture?: string; readonly currentStatus: TemplateLifecycleStatusValue; readonly workflowEnabled: boolean; readonly availableTransitions: readonly TemplateLifecycleStatusValue[]; } ``` ### Preview [Section titled “Preview”](#preview) ```ts interface TemplatePreviewRequest { readonly culture?: string; readonly format?: DocumentFormatValue; readonly data?: Record; } interface TemplatePreviewResponse { readonly html: string; readonly plainText?: string; readonly subject?: string; readonly revisionId: string; readonly renderTimeMs: number; } interface TemplateParseError { readonly message: string; readonly line?: number; readonly column?: number; readonly snippet?: string; } ``` ### Variables [Section titled “Variables”](#variables) ```ts interface TemplateVariable { readonly name: string; readonly type: string; readonly description?: string; readonly example?: string; } interface TemplateVariables { readonly globalVariables: readonly TemplateVariable[]; readonly modelVariables: readonly TemplateVariable[]; readonly enrichedVariables: readonly TemplateVariable[]; } ``` ### Categories [Section titled “Categories”](#categories) ```ts interface TemplateCategory { readonly id: string; readonly name: string; readonly description?: string; readonly icon?: string; readonly sortOrder: number; readonly templateCount: number; } interface CreateTemplateCategoryRequest { readonly name: string; readonly description?: string; readonly icon?: string; readonly sortOrder?: number; } interface UpdateTemplateCategoryRequest { readonly name: string; readonly description?: string; readonly icon?: string; readonly sortOrder: number; } ``` ## API functions [Section titled “API functions”](#api-functions) ### Template CRUD [Section titled “Template CRUD”](#template-crud) | Function | Endpoint | Description | | ----------------------------------------------- | -------------------------- | -------------------------------------- | | `getTemplates(client, basePath, params?)` | `GET /templates` | Paginated template list with filtering | | `getTemplate(client, basePath, name, culture?)` | `GET /templates/{name}` | Single template detail | | `saveDraft(client, basePath, request)` | `POST /templates` | Create a new draft | | `updateDraft(client, basePath, name, request)` | `PUT /templates/{name}` | Update an existing draft | | `deleteDraft(client, basePath, name, culture?)` | `DELETE /templates/{name}` | Delete a draft | ### Lifecycle [Section titled “Lifecycle”](#lifecycle-1) | Function | Endpoint | Description | | ----------------------------------------------------- | ---------------------------------- | ---------------------------------------- | | `publishTemplate(client, basePath, name, culture?)` | `POST /templates/{name}/publish` | Publish a template | | `unpublishTemplate(client, basePath, name, culture?)` | `POST /templates/{name}/unpublish` | Revert to draft | | `getLifecycleInfo(client, basePath, name, culture?)` | `GET /templates/{name}/lifecycle` | Current status and available transitions | ### History & preview [Section titled “History & preview”](#history--preview) | Function | Endpoint | Description | | -------------------------------------------------------- | -------------------------------------- | ---------------------------- | | `getHistory(client, basePath, name, params?)` | `GET /templates/{name}/history` | Paginated revision history | | `getRevision(client, basePath, name, revisionId)` | `GET /templates/{name}/revisions/{id}` | Specific revision content | | `previewTemplate(client, basePath, name, request)` | `POST /templates/{name}/preview` | Preview as HTML/text | | `previewTemplateBinary(client, basePath, name, request)` | `POST /templates/{name}/preview` | Preview as PDF/Excel (Blob) | | `getVariables(client, basePath, name)` | `GET /templates/{name}/variables` | Available template variables | ### Categories [Section titled “Categories”](#categories-1) | Function | Endpoint | Description | | ----------------------------------------------- | ---------------------------------- | ------------------- | | `getCategories(client, basePath)` | `GET /template-categories` | List all categories | | `createCategory(client, basePath, request)` | `POST /template-categories` | Create a category | | `updateCategory(client, basePath, id, request)` | `PUT /template-categories/{id}` | Update a category | | `deleteCategory(client, basePath, id)` | `DELETE /template-categories/{id}` | Delete a category | ## Provider & hooks [Section titled “Provider & hooks”](#provider--hooks) ### `TemplatingProvider` [Section titled “TemplatingProvider”](#templatingprovider) ```tsx interface TemplatingProviderProps { client: AxiosInstance; basePath?: string; // default: '/api/v1' queryKeyPrefix?: readonly string[]; // default: ['templates'] children: React.ReactNode; } {children} ``` ### Query hooks [Section titled “Query hooks”](#query-hooks) #### `useTemplates(params?)` [Section titled “useTemplates(params?)”](#usetemplatesparams) Paginated template list with filtering. ```ts function useTemplates(params?: TemplateListParams): UseQueryResult>; ``` #### `useTemplate(name, culture?)` [Section titled “useTemplate(name, culture?)”](#usetemplatename-culture) Single template detail. Auto-disables when `name` is falsy. ```ts function useTemplate(name: string, culture?: string): UseQueryResult; ``` #### `useTemplateVariables(name)` [Section titled “useTemplateVariables(name)”](#usetemplatevariablesname) Available template variables. Cached 5 minutes. ```ts function useTemplateVariables(name: string): UseQueryResult; ``` #### `useTemplateHistory(name, params?)` [Section titled “useTemplateHistory(name, params?)”](#usetemplatehistoryname-params) Paginated revision history. ```ts function useTemplateHistory( name: string, params?: { culture?: string; page?: number; pageSize?: number } ): UseQueryResult; ``` #### `useTemplateRevision(name, revisionId)` [Section titled “useTemplateRevision(name, revisionId)”](#usetemplaterevisionname-revisionid) Specific revision content. Auto-disables when either param is falsy. ```ts function useTemplateRevision(name: string, revisionId: string): UseQueryResult; ``` #### `useTemplateCategories()` [Section titled “useTemplateCategories()”](#usetemplatecategories) All template categories. Cached 5 minutes. ```ts function useTemplateCategories(): UseQueryResult; ``` ### Mutation hooks [Section titled “Mutation hooks”](#mutation-hooks) #### `useTemplateMutations()` [Section titled “useTemplateMutations()”](#usetemplatemutations) Grouped mutations for template CRUD and lifecycle. Invalidates related queries on success. ```ts interface UseTemplateMutationsReturn { readonly saveDraft: UseMutationResult; readonly updateDraft: UseMutationResult; readonly deleteDraft: UseMutationResult; readonly publish: UseMutationResult; readonly unpublish: UseMutationResult; } ``` #### `useTemplatePreview()` [Section titled “useTemplatePreview()”](#usetemplatepreview) Preview a template as HTML/text. ```ts function useTemplatePreview(): UseMutationResult< TemplatePreviewResponse, unknown, { name: string; request: TemplatePreviewRequest } >; ``` #### `useTemplateBinaryPreview()` [Section titled “useTemplateBinaryPreview()”](#usetemplatebinarypreview) Preview a template as a binary document (PDF or Excel). ```ts function useTemplateBinaryPreview(): UseMutationResult< Blob, unknown, { name: string; request: TemplatePreviewRequest } >; ``` #### `useTemplateCategoryMutations()` [Section titled “useTemplateCategoryMutations()”](#usetemplatecategorymutations) Category CRUD with automatic cache invalidation. ```ts interface UseTemplateCategoryMutationsReturn { readonly create: UseMutationResult; readonly update: UseMutationResult; readonly delete: UseMutationResult; } ``` ### Query keys [Section titled “Query keys”](#query-keys) For advanced cache control, the package exports a `templateKeys` factory: ```ts const templateKeys = { all: (prefix) => [...prefix], lists: (prefix) => [...prefix, 'list'], list: (prefix, params) => [...prefix, 'list', params], details: (prefix) => [...prefix, 'detail'], detail: (prefix, name) => [...prefix, 'detail', name], history: (prefix, name) => [...prefix, 'history', name], revision: (prefix, name, revisionId) => [...prefix, 'revision', name, revisionId], variables: (prefix, name) => [...prefix, 'variables', name], categories: (prefix) => [...prefix, 'categories'], } as const; ``` ### Template lifecycle [Section titled “Template lifecycle”](#template-lifecycle) ``` graph LR Draft["Draft"] --> PendingReview["Pending Review"] PendingReview --> Published["Published"] Published --> Archived["Archived"] PendingReview --> Draft Published --> Draft ``` ## Public API summary [Section titled “Public API summary”](#public-api-summary) | Category | Key exports | Package | | -------------- | ---------------------------------------------------------------------------------------------------------------- | -------------------- | | Enums | `TemplateLifecycleStatus`, `DocumentFormat` | `@granit/templating` | | Template types | `TemplateDetail`, `TemplateListItem`, `TemplateRevision`, `SaveTemplateRequest` | `@granit/templating` | | Preview types | `TemplatePreviewRequest`, `TemplatePreviewResponse`, `TemplateParseError` | `@granit/templating` | | Variable types | `TemplateVariable`, `TemplateVariables` | `@granit/templating` | | Category types | `TemplateCategory`, `CreateTemplateCategoryRequest` | `@granit/templating` | | API functions | `getTemplates()`, `saveDraft()`, `publishTemplate()`, `previewTemplate()`, `getVariables()` | `@granit/templating` | | Provider | `TemplatingProvider`, `useTemplatingConfig()` | `@granit/templating` | | Query hooks | `useTemplates()`, `useTemplate()`, `useTemplateVariables()`, `useTemplateHistory()`, `useTemplateCategories()` | `@granit/templating` | | Mutation hooks | `useTemplateMutations()`, `useTemplatePreview()`, `useTemplateBinaryPreview()`, `useTemplateCategoryMutations()` | `@granit/templating` | | Cache keys | `templateKeys` | `@granit/templating` | ## See also [Section titled “See also”](#see-also) * [Granit.Templating module](/reference/modules/templating/) — .NET Scriban template engine with EF store * [Workflow](./workflow/) — Templates use `WorkflowLifecycleStatus` for publication lifecycle * [Data Exchange](./data-exchange/) — Export templates can be managed through this system # Timeline > Activity stream with comments, internal notes, system logs, threaded replies, @mentions, and follower management `@granit/timeline` provides framework-agnostic types and API functions for an entity activity stream — mirroring `Granit.Timeline` on the .NET backend. It supports three entry types (comments, internal notes, system logs), threaded replies, file attachments via blob IDs, and `@mention` syntax. `@granit/react-timeline` wraps these into a provider and hooks with infinite scroll, optimistic updates, and follower management. **Peer dependencies:** `axios`, `@granit/logger`, `react ^19`, `@tanstack/react-query ^5` ## Package structure [Section titled “Package structure”](#package-structure) | Package | Role | Depends on | | ------------------------ | ------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | | `@granit/timeline` | DTOs, entry type enum, stream/follower API functions | `axios` | | `@granit/react-timeline` | `TimelineProvider`, `useTimeline`, `useTimelineActions`, `useTimelineFollowers` | `@granit/timeline`, `@granit/react-querying`, `@granit/logger`, `react` | ## Setup [Section titled “Setup”](#setup) * React (recommended) ```tsx import { TimelineProvider } from '@granit/react-timeline'; import { api } from './api-client'; function EntityDetail({ children }) { return ( {children} ); } ``` * TypeScript only ```ts import { fetchStream, createEntry, followEntity } from '@granit/timeline'; import type { TimelineStreamEntry, CreateTimelineEntryRequest } from '@granit/timeline'; ``` ## TypeScript SDK [Section titled “TypeScript SDK”](#typescript-sdk) ### Enums [Section titled “Enums”](#enums) ```ts const TimelineEntryType = { Comment: 0, // Visible to all users InternalNote: 1, // Restricted visibility (internal staff) SystemLog: 2, // Automatic system-generated entries } as const; type TimelineEntryTypeValue = (typeof TimelineEntryType)[keyof typeof TimelineEntryType]; ``` ### Types [Section titled “Types”](#types) ```ts interface TimelineStreamEntry { readonly id: string; readonly entityType: string; readonly entityId: string; readonly entryType: TimelineEntryTypeValue; readonly body: string; readonly authorId: string; readonly authorDisplayName: string; readonly parentEntryId: string | null; // null = top-level, set = threaded reply readonly createdAt: string; readonly attachmentBlobIds: string[]; } interface TimelineStreamPage { readonly items: TimelineStreamEntry[]; readonly totalCount: number; readonly nextCursor: string | null; } interface CreateTimelineEntryRequest { readonly entryType: TimelineEntryTypeValue; readonly body: string; readonly parentEntryId?: string; readonly attachmentBlobIds?: string[]; } interface TimelineQueryParams { readonly page?: number; readonly pageSize?: number; // default: 20 } interface MentionSuggestion { readonly id: string; readonly displayName: string; } ``` ### API functions [Section titled “API functions”](#api-functions) | Function | Endpoint | Description | | -------------------------------------------------------------- | ---------------------------------------------- | ---------------------------- | | `fetchStream(client, basePath, entityType, entityId, params?)` | `GET /{entityType}/{entityId}` | Paginated timeline stream | | `createEntry(client, basePath, entityType, entityId, request)` | `POST /{entityType}/{entityId}/entries` | Create comment, note, or log | | `deleteEntry(client, basePath, entityType, entityId, entryId)` | `DELETE /{entityType}/{entityId}/entries/{id}` | Delete an entry | | `followEntity(client, basePath, entityType, entityId)` | `POST /{entityType}/{entityId}/follow` | Follow entity | | `unfollowEntity(client, basePath, entityType, entityId)` | `DELETE /{entityType}/{entityId}/follow` | Unfollow entity | | `fetchFollowers(client, basePath, entityType, entityId)` | `GET /{entityType}/{entityId}/followers` | List follower user IDs | ### Mention syntax [Section titled “Mention syntax”](#mention-syntax) Mentions use the format `@[Display Name](user:guid)`. The server extracts mentioned user IDs from this pattern to trigger notifications. ## React bindings [Section titled “React bindings”](#react-bindings) ### `TimelineProvider` [Section titled “TimelineProvider”](#timelineprovider) ```tsx interface TimelineProviderProps { apiClient: AxiosInstance; basePath?: string; // default: '/api/v1/timeline' children: React.ReactNode; } {children} ``` ### `useTimeline(options)` [Section titled “useTimeline(options)”](#usetimelineoptions) Infinite-scroll timeline stream with optimistic update helpers. ```ts interface UseTimelineReturn { readonly entries: readonly TimelineStreamEntry[]; readonly totalCount: number; readonly loading: boolean; readonly loadingMore: boolean; readonly error: Error | null; readonly hasMore: boolean; readonly loadMore: () => void; readonly refresh: () => void; readonly addOptimisticEntry: (entry: TimelineStreamEntry) => void; readonly removeOptimisticEntry: (entryId: string) => void; } ``` ### `useTimelineActions(options)` [Section titled “useTimelineActions(options)”](#usetimelineactionsoptions) Create and delete timeline entries with callbacks for optimistic updates. ```ts interface UseTimelineActionsOptions { entityType: string; entityId: string; onEntryCreated?: (entry: TimelineStreamEntry) => void; onEntryDeleted?: (entryId: string) => void; } interface UseTimelineActionsReturn { readonly postEntry: (request: CreateTimelineEntryRequest) => Promise; readonly removeEntry: (entryId: string) => Promise; readonly posting: boolean; readonly deleting: boolean; readonly error: Error | null; } ``` ### `useTimelineFollowers(options)` [Section titled “useTimelineFollowers(options)”](#usetimelinefollowersoptions) Follower management with follow/unfollow toggle. ```ts interface UseTimelineFollowersOptions { entityType: string; entityId: string; currentUserId?: string; } interface UseTimelineFollowersReturn { readonly followers: string[]; readonly isFollowing: boolean; readonly loading: boolean; readonly error: Error | null; readonly follow: () => Promise; readonly unfollow: () => Promise; } ``` ### Optimistic update pattern [Section titled “Optimistic update pattern”](#optimistic-update-pattern) Wire `useTimelineActions` callbacks into `useTimeline` for instant UI feedback: ```tsx const timeline = useTimeline({ entityType: 'Invoice', entityId: id }); const actions = useTimelineActions({ entityType: 'Invoice', entityId: id, onEntryCreated: (entry) => timeline.addOptimisticEntry(entry), onEntryDeleted: (entryId) => timeline.removeOptimisticEntry(entryId), }); ``` ### Threading [Section titled “Threading”](#threading) Replies are entries with `parentEntryId` set to the parent entry’s ID. The rendering and indentation is a client-side concern — the hooks return a flat list that can be grouped by `parentEntryId`. ## Public API summary [Section titled “Public API summary”](#public-api-summary) | Category | Key exports | Package | | ------------- | ---------------------------------------------------------------------------------------------- | ------------------------ | | Enums | `TimelineEntryType` | `@granit/timeline` | | Types | `TimelineStreamEntry`, `CreateTimelineEntryRequest`, `TimelineStreamPage`, `MentionSuggestion` | `@granit/timeline` | | Stream API | `fetchStream()`, `createEntry()`, `deleteEntry()` | `@granit/timeline` | | Follower API | `followEntity()`, `unfollowEntity()`, `fetchFollowers()` | `@granit/timeline` | | Provider | `TimelineProvider`, `useTimelineConfig()` | `@granit/react-timeline` | | Stream hooks | `useTimeline()`, `useTimelineActions()` | `@granit/react-timeline` | | Follower hook | `useTimelineFollowers()` | `@granit/react-timeline` | ## See also [Section titled “See also”](#see-also) * [Granit.Timeline module](/reference/modules/timeline/) — .NET audit timeline with notification integration * [Notifications](./notifications/) — Mentions trigger notification delivery * [Storage](./storage/) — `attachmentBlobIds` reference blobs in the storage system # Tracing > OpenTelemetry browser tracing with OTLP export, auto-instrumentations, and custom span hooks `@granit/tracing` provides framework-agnostic OpenTelemetry utilities for browser tracing — mirroring `Granit.Observability` on the .NET backend. It exports a `getTraceContext()` function that reads the active span’s trace and span IDs for correlation with `@granit/logger-otlp`. `@granit/react-tracing` wraps this into a `TracingProvider` that initializes the OpenTelemetry `WebTracerProvider` with auto-instrumentations (fetch, XHR, document-load) and an OTLP HTTP exporter, plus hooks for creating custom spans. **Peer dependencies:** `@opentelemetry/api`, `@opentelemetry/sdk-trace-web`, `@opentelemetry/exporter-trace-otlp-http`, `react ^19` ## Package structure [Section titled “Package structure”](#package-structure) | Package | Role | Depends on | | ----------------------- | ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | | `@granit/tracing` | `getTraceContext()`, `TraceContext`, `TracingConfig` | `@opentelemetry/api` | | `@granit/react-tracing` | `TracingProvider`, `useTracer`, `useSpan` | `@granit/tracing`, `@opentelemetry/sdk-trace-web`, `@opentelemetry/exporter-trace-otlp-http`, `react` | ## Setup [Section titled “Setup”](#setup) * React (recommended) ```tsx import { TracingProvider } from '@granit/react-tracing'; function App({ children }) { return ( {children} ); } ``` * TypeScript only ```ts import { getTraceContext } from '@granit/tracing'; import type { TraceContext, TracingConfig } from '@granit/tracing'; const ctx = getTraceContext(); // { traceId: '...', spanId: '...' } or undefined ``` ## TypeScript SDK [Section titled “TypeScript SDK”](#typescript-sdk) ### Types [Section titled “Types”](#types) ```ts interface TraceContext { traceId: string; spanId: string; } interface TracingExporterConfig { url: string; headers?: Record; } interface TracingConfig { serviceName: string; serviceVersion?: string; exporter: TracingExporterConfig; instrumentFetch?: boolean; // default: true instrumentXhr?: boolean; // default: true instrumentDocumentLoad?: boolean; // default: true additionalInstrumentations?: Instrumentation[]; } ``` ### `getTraceContext()` [Section titled “getTraceContext()”](#gettracecontext) Reads the active span from the OpenTelemetry global context. Safe to call from non-React code — used as the `getTraceContext` callback for `@granit/logger-otlp`. ```ts function getTraceContext(): TraceContext | undefined; ``` ## React bindings [Section titled “React bindings”](#react-bindings) ### `TracingProvider` [Section titled “TracingProvider”](#tracingprovider) Initializes the OpenTelemetry `WebTracerProvider` with: * Auto-instrumentations: `fetch`, `XMLHttpRequest`, `document-load` * OTLP HTTP exporter with gated startup (probes the collector — if unavailable, disables export for the session with a warning instead of continuous errors) * Graceful shutdown on unmount (flushes pending spans) ```tsx {children} ``` ### `useTracer()` [Section titled “useTracer()”](#usetracer) Returns the OpenTelemetry `Tracer` instance from the provider. ```ts function useTracer(): Tracer; ``` ### `useSpan()` [Section titled “useSpan()”](#usespan) Helpers for creating custom spans within React components. ```ts interface UseSpanReturn { withSpan: (name: string, fn: (span: Span) => T | Promise) => Promise; createSpan: (name: string, options?: SpanOptions) => Span; } function useSpan(): UseSpanReturn; ``` * `withSpan` — Executes a function within a new span. The span is automatically ended on completion or rejection. Errors are recorded on the span. * `createSpan` — Creates a span for manual control. **You must call `span.end()` yourself.** ## Public API summary [Section titled “Public API summary”](#public-api-summary) | Category | Key exports | Package | | -------- | -------------------------------------------------------- | ----------------------- | | Types | `TraceContext`, `TracingConfig`, `TracingExporterConfig` | `@granit/tracing` | | Utility | `getTraceContext()` | `@granit/tracing` | | Provider | `TracingProvider` | `@granit/react-tracing` | | Hooks | `useTracer()`, `useSpan()` | `@granit/react-tracing` | ## See also [Section titled “See also”](#see-also) * [Granit.Observability module](/reference/modules/observability/) — .NET Serilog + OpenTelemetry → OTLP * [Logger](./logger/) — `@granit/logger-otlp` uses `getTraceContext()` for log-trace correlation # Utils > Tailwind CSS class merging, locale-aware date and number formatting utilities `@granit/utils` provides shared utility functions used across all Granit frontend applications. Class merging with Tailwind conflict resolution, locale-aware number formatting, and date formatting powered by `date-fns`. **Peer dependencies:** `clsx`, `tailwind-merge`, `date-fns` ## Setup [Section titled “Setup”](#setup) ```ts import { cn, formatDate, formatNumber } from '@granit/utils'; ``` No provider or initialization required — all functions are pure and stateless. ## API [Section titled “API”](#api) ### `cn(...inputs)` [Section titled “cn(...inputs)”](#cninputs) Merges CSS class names with Tailwind conflict resolution. Combines `clsx` (conditional classes) with `tailwind-merge` (last-wins for conflicting utilities). ```ts function cn(...inputs: ClassValue[]): string; ``` ```ts cn('px-4 py-2', 'px-6'); // "py-2 px-6" cn('text-sm', isActive && 'bg-primary'); // "text-sm bg-primary" (when active) cn('rounded-md', className); // safe to pass undefined className ``` ### `formatNumber(value, opts?)` [Section titled “formatNumber(value, opts?)”](#formatnumbervalue-opts) Formats a number using `Intl.NumberFormat` with the `en-US` locale. ```ts function formatNumber(value: number, opts?: Intl.NumberFormatOptions): string; ``` ```ts formatNumber(1234567); // "1,234,567" formatNumber(0.125, { style: 'percent' }); // "12.5%" formatNumber(99.956, { maximumFractionDigits: 1 }); // "100.0" ``` ### `formatDate(date)` [Section titled “formatDate(date)”](#formatdatedate) Formats a date to long readable form using `date-fns` format `PPP`. ```ts function formatDate(date: string | Date): string; ``` ```ts formatDate('2026-02-27'); // "February 27, 2026" formatDate(new Date()); // "March 13, 2026" ``` ### `formatDateTime(date)` [Section titled “formatDateTime(date)”](#formatdatetimedate) Formats a date with time using `date-fns` format `PPP HH:mm:ss`. ```ts function formatDateTime(date: string | Date): string; ``` ```ts formatDateTime('2026-02-27T14:30:00'); // "February 27, 2026 14:30:00" ``` ### `formatTimeAgo(date)` [Section titled “formatTimeAgo(date)”](#formattimeagodate) Formats a date as relative time using `date-fns` `formatDistanceToNow`. ```ts function formatTimeAgo(date: string | Date): string; ``` ```ts formatTimeAgo(twoHoursAgo); // "about 2 hours ago" formatTimeAgo(yesterday); // "1 day ago" ``` ### `calculatePercentage(value, total)` [Section titled “calculatePercentage(value, total)”](#calculatepercentagevalue-total) Calculates a percentage rounded to the nearest integer. Returns `0` when `total` is `0` (no division by zero). ```ts function calculatePercentage(value: number, total: number): number; ``` ```ts calculatePercentage(25, 100); // 25 calculatePercentage(1, 3); // 33 calculatePercentage(5, 0); // 0 ``` ## Public API summary [Section titled “Public API summary”](#public-api-summary) | Category | Key exports | Package | | ----------------- | ----------------------------------------------------- | --------------- | | CSS | `cn()` | `@granit/utils` | | Number formatting | `formatNumber()`, `calculatePercentage()` | `@granit/utils` | | Date formatting | `formatDate()`, `formatDateTime()`, `formatTimeAgo()` | `@granit/utils` | ## See also [Section titled “See also”](#see-also) * [Granit.Core module](/reference/modules/core/) — .NET foundation types * [Querying](./querying/) — uses `cn()` for component styling, `formatDate()` for filter display # Workflow > Finite state machine with transitions, approval routing, audit history, and lifecycle status `@granit/workflow` provides framework-agnostic types and API functions for a finite state machine — mirroring `Granit.Workflow` on the .NET backend. It models entity lifecycle through named states, guarded transitions, and optional approval routing. `@granit/react-workflow` wraps these into a provider and hooks that manage status fetching, transition execution, and paginated audit history. **Peer dependencies:** `axios`, `@granit/logger`, `react ^19` ## Package structure [Section titled “Package structure”](#package-structure) | Package | Role | Depends on | | ------------------------ | -------------------------------------------------------------------------------------- | --------------------------------------------- | | `@granit/workflow` | DTOs, transition outcome enum, API functions | `axios` | | `@granit/react-workflow` | `WorkflowProvider`, `useWorkflowStatus`, `useWorkflowTransition`, `useWorkflowHistory` | `@granit/workflow`, `@granit/logger`, `react` | ## Setup [Section titled “Setup”](#setup) * React (recommended) ```tsx import { WorkflowProvider } from '@granit/react-workflow'; import { api } from './api-client'; function App({ children }) { return ( {children} ); } ``` * TypeScript only ```ts import { fetchStatus, executeTransition, fetchHistory } from '@granit/workflow'; import type { WorkflowStatusDto, TransitionResultDto } from '@granit/workflow'; ``` ## TypeScript SDK [Section titled “TypeScript SDK”](#typescript-sdk) ### Enums [Section titled “Enums”](#enums) ```ts const TransitionOutcome = { Completed: 'Completed', ApprovalRequested: 'ApprovalRequested', Denied: 'Denied', InvalidTransition: 'InvalidTransition', } as const; type TransitionOutcomeValue = (typeof TransitionOutcome)[keyof typeof TransitionOutcome]; const WorkflowLifecycleStatus = { Draft: 0, PendingReview: 1, Published: 2, Archived: 3, } as const; type WorkflowLifecycleStatusValue = (typeof WorkflowLifecycleStatus)[keyof typeof WorkflowLifecycleStatus]; ``` ### Types [Section titled “Types”](#types) ```ts interface TransitionDto { readonly targetState: string; readonly name: string; readonly allowed: boolean; readonly requiresApproval: boolean; } interface TransitionRequestDto { readonly targetState: string; readonly comment?: string; } interface TransitionResultDto { readonly succeeded: boolean; readonly resultingState: string; readonly outcome: TransitionOutcomeValue; } interface TransitionHistoryDto { readonly previousState: string; readonly newState: string; readonly transitionedAt: string; readonly transitionedBy: string; readonly comment: string | null; } interface WorkflowStatusDto { readonly currentState: string; readonly availableTransitions: TransitionDto[]; } interface WorkflowHistoryPage { items: TransitionHistoryDto[]; totalCount: number; nextCursor: string | null; } ``` ### API functions [Section titled “API functions”](#api-functions) | Function | Endpoint | Description | | -------------------------------------------------------------------- | ------------------------------------------ | --------------------------------------- | | `fetchStatus(client, basePath, entityType, entityId)` | `GET /{entityType}/{entityId}/transitions` | Current state and available transitions | | `executeTransition(client, basePath, entityType, entityId, request)` | `POST /{entityType}/{entityId}/transition` | Trigger a transition | | `fetchHistory(client, basePath, entityType, entityId, params?)` | `GET /{entityType}/{entityId}/history` | Paginated transition history | ## React bindings [Section titled “React bindings”](#react-bindings) ### `WorkflowProvider` [Section titled “WorkflowProvider”](#workflowprovider) ```tsx interface WorkflowProviderProps { apiClient: AxiosInstance; basePath?: string; // default: '/api/v1/workflow' children: React.ReactNode; } {children} ``` ### `useWorkflowStatus(options)` [Section titled “useWorkflowStatus(options)”](#useworkflowstatusoptions) Fetches current state and available transitions for an entity. ```ts interface UseWorkflowStatusReturn { readonly currentState: string | null; readonly transitions: TransitionDto[]; readonly loading: boolean; readonly error: Error | null; readonly refetch: () => Promise; } ``` ### `useWorkflowTransition(options)` [Section titled “useWorkflowTransition(options)”](#useworkflowtransitionoptions) Executes a state transition with optional audit comment. ```ts interface UseWorkflowTransitionOptions { entityType: string; entityId: string; onSuccess?: (result: TransitionResultDto) => void; onError?: (error: Error) => void; } interface UseWorkflowTransitionReturn { readonly transition: (targetState: string, comment?: string) => Promise; readonly loading: boolean; readonly result: TransitionResultDto | null; readonly error: Error | null; } ``` ### `useWorkflowHistory(options)` [Section titled “useWorkflowHistory(options)”](#useworkflowhistoryoptions) Fetches paginated transition audit trail for an entity. ```ts interface UseWorkflowHistoryOptions { entityType: string; entityId: string; enabled?: boolean; // default: true } interface UseWorkflowHistoryReturn { readonly history: TransitionHistoryDto[]; readonly loading: boolean; readonly error: Error | null; readonly refetch: () => Promise; } ``` ### Transition lifecycle [Section titled “Transition lifecycle”](#transition-lifecycle) ``` graph LR Fetch["Fetch status"] --> Check["Check transitions"] Check --> Execute["Execute transition"] Execute --> Result{"Outcome?"} Result -->|Completed| Refetch["Refetch status"] Result -->|ApprovalRequested| Pending["Pending approval"] Result -->|Denied| Error["Show error"] ``` ### Approval routing [Section titled “Approval routing”](#approval-routing) When a transition has `requiresApproval: true`, calling `executeTransition` returns `outcome: 'ApprovalRequested'` and the entity enters a pending state. An approver must then complete or deny the transition via the same API. ## Public API summary [Section titled “Public API summary”](#public-api-summary) | Category | Key exports | Package | | ------------- | ----------------------------------------------------------------------------------- | ------------------------ | | Enums | `TransitionOutcome`, `WorkflowLifecycleStatus` | `@granit/workflow` | | Types | `WorkflowStatusDto`, `TransitionDto`, `TransitionResultDto`, `TransitionHistoryDto` | `@granit/workflow` | | API functions | `fetchStatus()`, `executeTransition()`, `fetchHistory()` | `@granit/workflow` | | Provider | `WorkflowProvider`, `useWorkflowConfig()` | `@granit/react-workflow` | | Hooks | `useWorkflowStatus()`, `useWorkflowTransition()`, `useWorkflowHistory()` | `@granit/react-workflow` | ## See also [Section titled “See also”](#see-also) * [Granit.Workflow module](/reference/modules/workflow/) — .NET FSM engine with publication lifecycle * [Timeline](./timeline/) — Audit entries can reference workflow transitions * [Templating](./templating/) — Templates use `WorkflowLifecycleStatus` for publication lifecycle # HTTP Conventions > Status codes, Problem Details, DTO naming, pagination, and response format conventions This page documents the HTTP conventions used across all Granit endpoints. These conventions ensure consistent behavior for API consumers, generated clients, and OpenAPI documentation. ## Status codes [Section titled “Status codes”](#status-codes) ### Success (2xx) [Section titled “Success (2xx)”](#success-2xx) | Code | Name | When to use | Response body | | ---- | ------------ | -------------------------------------- | ---------------------------------- | | 200 | OK | Request processed, result in response | Yes | | 201 | Created | Resource created | Yes + `Location` header | | 202 | Accepted | Asynchronous processing started | Yes (tracking ID or job reference) | | 204 | No Content | Operation succeeded, nothing to return | No | | 207 | Multi-Status | Batch operation with mixed results | Yes (`BatchResult`) | **200 vs 202**: Use 200 when the response is immediate and complete. Use 202 when processing is deferred (export generation, GDPR erasure, background jobs). A 202 response must always include a tracking mechanism (body with `requestId`/`jobId`, `Location` header for polling, or webhook notification). **204 use cases**: `DELETE` operations, `PUT /settings`, acknowledgment endpoints like `POST /notifications/mark-read`. ### Client errors (4xx) [Section titled “Client errors (4xx)”](#client-errors-4xx) | Code | Name | When to use | | ---- | -------------------- | ---------------------------------------------------------- | | 400 | Bad Request | Syntactically invalid request (malformed JSON, wrong type) | | 401 | Unauthorized | Token absent or expired | | 403 | Forbidden | Authenticated but not authorized | | 404 | Not Found | Resource not found (routes with `{id}` parameter) | | 409 | Conflict | Concurrency conflict (stale version, duplicate) | | 422 | Unprocessable Entity | Business validation failed (FluentValidation rules) | | 429 | Too Many Requests | Rate limiting triggered | ### Server errors (5xx) [Section titled “Server errors (5xx)”](#server-errors-5xx) | Code | Name | When to use | | ---- | --------------------- | -------------------------------------------------- | | 500 | Internal Server Error | Unexpected server-side error | | 502 | Bad Gateway | Upstream service unavailable (Keycloak, Vault, S3) | | 503 | Service Unavailable | Application in maintenance or overloaded | ## RFC 7807 Problem Details [Section titled “RFC 7807 Problem Details”](#rfc-7807-problem-details) All error responses (4xx and 5xx) use the `application/problem+json` content type defined by [RFC 7807](https://tools.ietf.org/html/rfc7807). ### Required pattern [Section titled “Required pattern”](#required-pattern) Always use `TypedResults.Problem()`. Never use `TypedResults.BadRequest()`. ```csharp // Correct return TypedResults.Problem( detail: "The template name exceeds 200 characters.", statusCode: StatusCodes.Status422UnprocessableEntity); // Wrong - produces application/json, not application/problem+json return TypedResults.BadRequest("The template name exceeds 200 characters."); ``` The Roslyn analyzer **GRAPI002** flags `TypedResults.BadRequest` calls that include a body argument and offers an automatic code fix. ### Response format [Section titled “Response format”](#response-format) ```json { "type": "https://tools.ietf.org/html/rfc7807", "title": "Validation failed", "status": 422, "detail": "The NISS format is invalid.", "instance": "/api/v1/patients" } ``` ### Return type declarations [Section titled “Return type declarations”](#return-type-declarations) Use union types so OpenAPI generates correct response schemas: ```csharp private static async Task, NotFound, ProblemHttpResult>> HandleGetDetailAsync(/* parameters */) ``` The `ProblemDetailsResponseOperationTransformer` automatically declares error responses in the OpenAPI documentation. ### Validation errors [Section titled “Validation errors”](#validation-errors) `FluentValidationEndpointFilter` returns `422 Unprocessable Entity` with `HttpValidationProblemDetails` containing structured error codes: ```json { "type": "https://tools.ietf.org/html/rfc7807", "title": "One or more validation errors occurred.", "status": 422, "errors": { "Name": ["Granit:Validation:NotEmptyValidator"] } } ``` ## DTO naming conventions [Section titled “DTO naming conventions”](#dto-naming-conventions) ### Suffixes [Section titled “Suffixes”](#suffixes) | Suffix | Usage | Example | | ----------- | ----------------------------- | ------------------------ | | `*Request` | Input bodies (POST/PUT/PATCH) | `TemplateCreateRequest` | | `*Response` | Top-level return types | `TemplateDetailResponse` | Never use the `*Dto` suffix. EF Core entities must never be returned directly from endpoints. Always create a `*Response` record. ### Module prefix [Section titled “Module prefix”](#module-prefix) Module-specific DTOs must be prefixed with their module context. OpenAPI flattens C# namespaces, so generic names cause schema conflicts. ```csharp // Correct - prefixed with module context public sealed record WorkflowTransitionRequest(string TargetState); public sealed record TemplateCreateRequest(string Name, string Content); // Wrong - ambiguous in flattened OpenAPI schema public sealed record TransitionRequest(string TargetState); public sealed record CreateRequest(string Name, string Content); ``` Shared cross-cutting types are exempt from the prefix rule: `PagedResult`, `ProblemDetails`, `BatchResult`. ## Pagination [Section titled “Pagination”](#pagination) Granit supports two pagination modes via `QueryDefinition`. ### Offset pagination (default) [Section titled “Offset pagination (default)”](#offset-pagination-default) ```text GET /api/v1/patients?page=1&pageSize=20&sort=-createdAt ``` | Parameter | Type | Default | Description | | ---------------- | -------- | --------------------- | ------------------------------------------------- | | `page` | `int` | `1` | Page number (1-based) | | `pageSize` | `int` | `20` | Page size (capped at `MaxPageSize`, default 100) | | `sort` | `string` | Per `QueryDefinition` | Comma-separated fields, `-` prefix for descending | | `skipTotalCount` | `bool` | `false` | Skip the `COUNT(*)` query for large tables | Response: ```json { "items": [], "totalCount": 142, "hasMore": true } ``` When `skipTotalCount=true`, `totalCount` is `null` and `hasMore` is determined by fetching `pageSize + 1` rows. ### Cursor pagination (keyset) [Section titled “Cursor pagination (keyset)”](#cursor-pagination-keyset) Opt-in mode for real-time feeds, infinite scroll, and large datasets. Enabled via `SupportsCursorPagination()` in the `QueryDefinition`. ```text GET /api/v1/events?pageSize=50 GET /api/v1/events?cursor=eyJpZCI6MTIzfQ&pageSize=50 ``` | Parameter | Type | Description | | ---------- | -------- | --------------------------------------------- | | `cursor` | `string` | Opaque cursor (Base64). Absent for first page | | `pageSize` | `int` | Page size | Response: ```json { "items": [], "totalCount": null, "nextCursor": "eyJpZCI6MTczfQ", "hasMore": true } ``` The cursor is opaque. Clients must never decode or construct it. ### PagedResult type [Section titled “PagedResult type”](#pagedresult-type) ```csharp public sealed record PagedResult( IReadOnlyList Items, int? TotalCount, bool HasMore, string? NextCursor = null); ``` ### Choosing between modes [Section titled “Choosing between modes”](#choosing-between-modes) | Criterion | Offset | Cursor | | --------------------------- | -------------------------- | ------------------------- | | Page number navigation | Yes | No | | Total count | Yes (opt-out available) | No | | Frequently changing data | Results may skip/duplicate | Stable | | Performance on large tables | `OFFSET N` degrades with N | Constant (`WHERE id > X`) | | Infinite scroll / real-time | Not recommended | Recommended | An endpoint can support both modes when the `QueryDefinition` declares `SupportsCursorPagination()`. The mode is determined by the presence of the `cursor` parameter. ## Sorting [Section titled “Sorting”](#sorting) Comma-separated field names. Prefix `-` for descending order: ```text GET /api/v1/patients?sort=-createdAt,lastName ``` Only fields declared `Sortable()` in the `QueryDefinition` are accepted. An unsortable field returns **400 Bad Request**. ## Filtering [Section titled “Filtering”](#filtering) ### Field filters [Section titled “Field filters”](#field-filters) Syntax: `filter[field.operator]=value` ```text GET /api/v1/patients?filter[name.contains]=Alice&filter[age.gte]=18 ``` Supported operators: `eq`, `contains`, `startsWith`, `endsWith`, `gt`, `gte`, `lt`, `lte`, `in`, `between`. ### Presets [Section titled “Presets”](#presets) ```text GET /api/v1/patients?presets[status]=active,pending ``` ### Quick filters [Section titled “Quick filters”](#quick-filters) ```text GET /api/v1/patients?quickFilters=MyPatients,Unread ``` ### Global search [Section titled “Global search”](#global-search) ```text GET /api/v1/patients?search=Alice ``` Searches fields declared via `GlobalSearch()` in the `QueryDefinition`. ## Content types [Section titled “Content types”](#content-types) | Content type | Usage | | -------------------------- | --------------------------------------- | | `application/json` | Default for all request/response bodies | | `application/problem+json` | All error responses (RFC 7807) | | `multipart/form-data` | File uploads | | `application/octet-stream` | File downloads | ### JSON conventions [Section titled “JSON conventions”](#json-conventions) * **Properties**: `camelCase` (default `System.Text.Json` behavior) * **Enums**: PascalCase strings via `JsonStringEnumConverter` (e.g., `"InProgress"`) * **Dates**: ISO 8601 with timezone (`2025-03-15T10:30:00Z`) * **Identifiers**: UUID v7 with dashes (`"0193a5b2-7c3d-7def-8a12-bc3456789abc"`) ## Batch operations (207 Multi-Status) [Section titled “Batch operations (207 Multi-Status)”](#batch-operations-207-multi-status) For operations processing multiple resources where individual items can fail independently: ```json { "results": [ { "value": { "id": "abc", "status": "Created" }, "isSuccess": true, "error": null }, { "value": null, "isSuccess": false, "error": { "status": 422, "detail": "Invalid format" } } ], "successCount": 1, "failureCount": 1 } ``` Use 207 for: data imports, bulk updates, multi-recipient notifications. Do not use 207 for single-resource operations or transactional (all-or-nothing) operations. ## Endpoint conventions [Section titled “Endpoint conventions”](#endpoint-conventions) ### URL structure [Section titled “URL structure”](#url-structure) * **Resource segments**: `kebab-case` (`/background-jobs`, `/reference-data`) * **Route parameters**: `camelCase` (`{patientId}`, `{tenantId}`) * **Prefix**: `/api` is an application-level decision, not enforced by the framework ```csharp // Application with UI - /api prefix var api = app.MapGroup("api/v{version:apiVersion}") .WithApiVersionSet(apiVersionSet); // Pure API service - no prefix var api = app.MapGroup("v{version:apiVersion}") .WithApiVersionSet(apiVersionSet); ``` ### Route groups [Section titled “Route groups”](#route-groups) Endpoints are organized by `MapGroup` with OpenAPI tags: ```csharp RouteGroupBuilder group = endpoints.MapGroup(prefix) .RequireAuthorization() .WithTags("MobilePush"); ``` ### Validation filter [Section titled “Validation filter”](#validation-filter) Attach FluentValidation to endpoints via `.ValidateBody()`: ```csharp group.MapPost("/", HandleCreate) .ValidateBody(); ``` Modules with `[assembly: WolverineHandlerModule]` get automatic validator discovery via `AddGranitWolverine()`. Modules without Wolverine handlers must call `AddGranitValidatorsFromAssemblyContaining()` manually. Without registration, `FluentValidationEndpointFilter` silently skips validation. ## Idempotency [Section titled “Idempotency”](#idempotency) The `Granit.Idempotency` package provides Stripe-style idempotency via the `Idempotency-Key` HTTP header, backed by Redis. ### Header [Section titled “Header”](#header) | Header | Format | Description | | ----------------- | ---------------------------------------- | ------------------------ | | `Idempotency-Key` | Client-generated string (typically UUID) | Unique key per operation | ### Behavior [Section titled “Behavior”](#behavior) | Scenario | Response | | ----------------------------- | -------------------------------------------------------- | | First request | Executes handler, caches response for 24h | | Duplicate with same body | Replays cached response + `X-Idempotency-Replayed: true` | | Duplicate with different body | `422` with detail “payload does not match” | | Concurrent duplicate | `409` with `Retry-After` header | | Missing required key | `422` with detail about missing header | | Multipart request | `422` (non-deterministic boundaries prevent hashing) | ### Configuration [Section titled “Configuration”](#configuration) ```json { "Idempotency": { "HeaderName": "Idempotency-Key", "CompletedTtl": "1.00:00:00", "InProgressTtl": "00:00:30", "ExecutionTimeout": "00:00:25" } } ``` Cached status codes: 2xx + 400, 404, 409, 410, 422. Authentication errors (401/403) are never cached. ## API versioning [Section titled “API versioning”](#api-versioning) URL-segment versioning with query string fallback, powered by `Asp.Versioning`. ### Version readers (priority order) [Section titled “Version readers (priority order)”](#version-readers-priority-order) 1. **URL segment**: `/api/v{version:apiVersion}/resource` (primary) 2. **Query string**: `?api-version=1.0` (fallback, visible in access logs) ### Configuration [Section titled “Configuration”](#configuration-1) ```json { "ApiVersioning": { "DefaultMajorVersion": 1, "ReportApiVersions": true } } ``` When `ReportApiVersions` is `true`, every response includes `api-supported-versions` and `api-deprecated-versions` headers. ### Deprecation headers [Section titled “Deprecation headers”](#deprecation-headers) When an endpoint or API version is deprecated: | Header | Format | Description | | ------------- | -------------------------- | ----------------------------------------- | | `Deprecation` | `true` | Endpoint is deprecated | | `Sunset` | HTTP-date (RFC 7231) | Date after which endpoint will be removed | | `Link` | `; rel="deprecation"` | Link to migration documentation | ## CORS [Section titled “CORS”](#cors) Configured via the `Cors` section in `appsettings.json`: ```json { "Cors": { "AllowedOrigins": ["https://app.example.com", "https://admin.example.com"], "AllowCredentials": false } } ``` The default policy allows any header and any method for the configured origins. Wildcard (`*`) origins are rejected in non-development environments (ISO 27001 compliance). ## Cacheability [Section titled “Cacheability”](#cacheability) | Endpoint type | Cache-Control | Rationale | | -------------------------------------- | ---------------------------------- | ------------------------------ | | User data (`GET /me`, `/settings`) | `private, no-cache` | Personal data, must revalidate | | Reference data (`GET /reference-data`) | `public, max-age=3600` | Rarely changes, shared | | Paginated lists (`GET /patients`) | `private, no-store` | Sensitive data (GDPR) | | Static resources (images, documents) | `public, max-age=86400, immutable` | Content-addressable | | Query metadata (`GET /meta`) | `public, max-age=3600` | Stable configuration | GDPR constraint: responses containing personal data (PII) must never use `public` in `Cache-Control`. Use `private` or `no-store` to prevent intermediate caches (CDN, reverse proxy) from storing sensitive data. # Granit.Analyzers > Roslyn analyzers enforcing Granit conventions at compile time with IDE feedback Granit.Analyzers provides Roslyn analyzers that enforce Granit conventions **at compile time**, with immediate feedback in the IDE (red squiggles). They complement runtime checks and architecture tests with zero-cost static analysis. ## Installation [Section titled “Installation”](#installation) ```bash dotnet add package Granit.Analyzers ``` The package ships both analyzers and code fix providers. No additional configuration is required — rules activate automatically based on the project’s dependencies. ## Rules [Section titled “Rules”](#rules) ### Architecture [Section titled “Architecture”](#architecture) Rules enforcing modular monolith boundaries. | Rule | Severity | CodeFix | Description | | -------- | -------- | ------- | ------------------------------------------------------- | | GRMOD001 | Error | Yes | Cross-module reference to internal type — use Contracts | #### GRMOD001 — Cross-module reference detection [Section titled “GRMOD001 — Cross-module reference detection”](#grmod001--cross-module-reference-detection) Detects when code in one module references internal types from another module instead of using the `.Contracts` namespace. **Applies to**: projects using the `*.Modules.{ModuleName}` namespace convention. **Opt-in**: the analyzer only activates when `*.Modules.*` namespaces are present in the compilation. Projects without this convention pay zero runtime cost. **Allowed references**: * Types in `*.Modules.{Module}.Contracts` and sub-namespaces * Types within the same module * Types outside `*.Modules.*` (framework, BCL, shared libraries) **Forbidden references**: * Types in `*.Modules.{OtherModule}.Domain` * Types in `*.Modules.{OtherModule}.Handlers` * Types in `*.Modules.{OtherModule}.Services` * Any non-Contracts namespace of another module **CodeFix**: if a type with the same name exists in the target module’s `.Contracts` namespace, the code fix suggests replacing the `using` directive. * Bad ```csharp // ❌ GRMOD001: InventoryService from module Inventory cannot be // referenced from module Orders using MyApp.Modules.Inventory.Services; namespace MyApp.Modules.Orders.Handlers; public class OrderHandler(InventoryService inventory) { } ``` * Good ```csharp // ✅ Use the Contracts namespace using MyApp.Modules.Inventory.Contracts; namespace MyApp.Modules.Orders.Handlers; public class OrderHandler(IInventoryReader inventory) { } ``` ### Migrations [Section titled “Migrations”](#migrations) Rules enforcing zero-downtime migration patterns (expand/migrate/contract). | Rule | Severity | CodeFix | Description | | --------- | -------- | ------- | ---------------------------------------------------------- | | GRMIGA001 | Error | — | DropColumn requires a Contract-phase annotation | | GRMIGA002 | Error | — | RenameColumn is not zero-downtime safe | | GRMIGA003 | Warning | — | AddColumn NOT NULL without default risks table lock | | GRMIGA004 | Warning | — | AlterColumn type change requires Contract-phase annotation | ### Security [Section titled “Security”](#security) Rules enforcing testability, secret management, and GDPR compliance. | Rule | Severity | CodeFix | Description | | -------- | -------- | ------- | -------------------------------------------------------- | | GRSEC001 | Warning | Yes | Avoid DateTime.Now/UtcNow — use IClock | | GRSEC002 | Warning | Yes | Avoid Guid.NewGuid() — use IGuidGenerator | | GRSEC003 | Error | — | Potential hardcoded secret detected | | GRSEC004 | Warning | Yes | Avoid direct IResponseCookies — use IGranitCookieManager | ### Entity Framework [Section titled “Entity Framework”](#entity-framework) Rules enforcing async-first EF Core patterns. | Rule | Severity | CodeFix | Description | | ------- | -------- | ------- | ----------------------------------------------- | | GREF001 | Warning | Yes | Use SaveChangesAsync() instead of SaveChanges() | ### API [Section titled “API”](#api) Rules enforcing Minimal API conventions and RFC 7807 compliance. | Rule | Severity | CodeFix | Description | | -------- | -------- | ------- | ----------------------------------------------------------- | | GRAPI001 | Warning | Yes | Use TypedResults instead of Results for OpenAPI | | GRAPI002 | Warning | Yes | Use TypedResults.Problem() instead of BadRequest (RFC 7807) | ## Suppressing rules [Section titled “Suppressing rules”](#suppressing-rules) To suppress a specific rule for a file or project: ```csharp // File-level suppression #pragma warning disable GRMOD001 ``` ```xml $(NoWarn);GRMOD001 ``` Caution Architecture rules (GRMOD) are also enforced by NetArchTest at CI time. Suppressing the analyzer does not bypass the CI gate. # API & Web Infrastructure > CORS, API versioning, OpenAPI documentation, exception handling, idempotency, and rate limiting Six packages that form the HTTP infrastructure layer of a Granit application. CORS policy enforcement (ISO 27001-compliant wildcard rejection), URL-based API versioning with deprecation headers, Scalar OpenAPI documentation with OAuth2/PKCE, RFC 7807 Problem Details exception handling, Stripe-style idempotency middleware with Redis state machine, and per-tenant rate limiting with four algorithms. ## Package structure [Section titled “Package structure”](#package-structure) | Package | Role | Depends on | | -------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------- | | `Granit.Cors` | CORS default policy, ISO 27001 wildcard rejection | `Granit.Core` | | `Granit.ApiVersioning` | Asp.Versioning, URL segment + query string | `Granit.Core` | | `Granit.ApiDocumentation` | Scalar OpenAPI, OAuth2/PKCE, multi-version docs | `Granit.ApiVersioning`, `Granit.Security` | | `Granit.ExceptionHandling` | RFC 7807 Problem Details, `IExceptionStatusCodeMapper` chain | `Granit.Core` | | `Granit.Idempotency` | `Idempotency-Key` middleware, Redis state machine | `Granit.Caching`, `Granit.Security` | | `Granit.RateLimiting` | Per-tenant rate limiting, 4 algorithms, Wolverine middleware | `Granit.Core`, `Granit.ExceptionHandling`, `Granit.Features`, `Granit.Security` | ## Dependency graph [Section titled “Dependency graph”](#dependency-graph) ``` graph TD CORS[Granit.Cors] --> CO[Granit.Core] AV[Granit.ApiVersioning] --> CO AD[Granit.ApiDocumentation] --> AV AD --> SEC[Granit.Security] EH[Granit.ExceptionHandling] --> CO ID[Granit.Idempotency] --> CA[Granit.Caching] ID --> SEC RL[Granit.RateLimiting] --> CO RL --> EH RL --> FT[Granit.Features] RL --> SEC ``` *** ## Granit.Cors [Section titled “Granit.Cors”](#granitcors) Standardized CORS configuration driven by `appsettings.json`. Wildcard origins are rejected at startup in non-development environments (ISO 27001 compliance). ### Setup [Section titled “Setup”](#setup) ```csharp [DependsOn(typeof(GranitCorsModule))] public class AppModule : GranitModule { } ``` ```json { "Cors": { "AllowedOrigins": ["https://app.example.com", "https://admin.example.com"], "AllowCredentials": false } } ``` In `Program.cs`: ```csharp app.UseCors(); // Uses the default policy configured by the module ``` ### Validation rules [Section titled “Validation rules”](#validation-rules) | Rule | Enforced at | Environment | | -------------------------------------- | ----------- | ------------------------ | | At least one origin required | Startup | All | | Wildcard `*` forbidden | Startup | Non-development | | `AllowCredentials` + wildcard rejected | Startup | All (CORS specification) | ### Configuration reference [Section titled “Configuration reference”](#configuration-reference) | Property | Default | Description | | ------------------ | ------- | ------------------------------------------------ | | `AllowedOrigins` | `[]` | Allowed CORS origins (required, minimum 1) | | `AllowCredentials` | `false` | Include `Access-Control-Allow-Credentials: true` | The default policy applies `AllowAnyHeader()` and `AllowAnyMethod()`, which is standard for REST APIs. Origins are restricted to the configured list. *** ## Granit.ApiVersioning [Section titled “Granit.ApiVersioning”](#granitapiversioning) URL-based API versioning using [Asp.Versioning](https://github.com/dotnet/aspnet-api-versioning). Primary reader: URL segment (`/api/v{version:apiVersion}/resource`). Fallback reader: query string (`?api-version=1.0`). ### Setup [Section titled “Setup”](#setup-1) ```csharp [DependsOn(typeof(GranitApiVersioningModule))] public class AppModule : GranitModule { } ``` ```json { "ApiVersioning": { "DefaultMajorVersion": 1, "ReportApiVersions": true } } ``` ### Usage [Section titled “Usage”](#usage) ```csharp var v1 = app.NewVersionedApi("Appointments").MapGroup("/api/v{version:apiVersion}"); var v1Group = v1.MapGroup("/appointments").HasApiVersion(1); v1Group.MapGet("/", GetAppointments); v1Group.MapPost("/", CreateAppointment); var v2Group = v1.MapGroup("/appointments").HasApiVersion(2); v2Group.MapGet("/", GetAppointmentsV2); ``` ### Deprecation headers (RFC 8594) [Section titled “Deprecation headers (RFC 8594)”](#deprecation-headers-rfc-8594) Mark endpoints as deprecated with automatic `Deprecation`, `Sunset`, and `Link` response headers: ```csharp v1Group.MapGet("/legacy-patients", GetLegacyPatients) .Deprecated(sunsetDate: "2026-06-01", link: "https://docs.example.com/migration/v2"); ``` **Response headers:** ```http Deprecation: true Sunset: Sun, 01 Jun 2026 00:00:00 GMT Link: ; rel="deprecation" ``` Each call to a deprecated endpoint is logged at Warning level. ### Configuration reference [Section titled “Configuration reference”](#configuration-reference-1) | Property | Default | Description | | --------------------- | ------- | ---------------------------------------------------------------------- | | `DefaultMajorVersion` | `1` | Default API version when client does not specify one | | `ReportApiVersions` | `true` | Include `api-supported-versions` and `api-deprecated-versions` headers | *** ## Granit.ApiDocumentation [Section titled “Granit.ApiDocumentation”](#granitapidocumentation) OpenAPI document generation and [Scalar](https://scalar.com) interactive UI. Generates one OpenAPI document per declared API major version. Supports JWT Bearer and OAuth2 Authorization Code with PKCE for interactive authentication. ### Setup [Section titled “Setup”](#setup-2) * JWT Bearer (default) ```csharp [DependsOn(typeof(GranitApiDocumentationModule))] public class AppModule : GranitModule { } ``` ```json { "ApiDocumentation": { "Title": "Clinic API", "MajorVersions": [1, 2], "Description": "Patient and appointment management API" } } ``` * OAuth2 / Keycloak ```json { "ApiDocumentation": { "Title": "Clinic API", "MajorVersions": [1], "OAuth2": { "AuthorizationUrl": "https://keycloak.example.com/realms/clinic/protocol/openid-connect/auth", "TokenUrl": "https://keycloak.example.com/realms/clinic/protocol/openid-connect/token", "ClientId": "clinic-scalar", "EnablePkce": true, "Scopes": ["openid", "profile"] } } } ``` When `OAuth2` is fully configured, the Bearer scheme is replaced with an OAuth2 Authorization Code flow in the OpenAPI document, and Scalar enables interactive PKCE-based authentication. In `Program.cs`: ```csharp app.UseGranitApiDocumentation(); // Maps /openapi/v1.json, /openapi/v2.json, /scalar ``` ### Schema examples [Section titled “Schema examples”](#schema-examples) Provide realistic example values for request DTOs without coupling to OpenAPI: ```csharp public class AppointmentSchemaExamples : ISchemaExampleProvider { public IReadOnlyDictionary GetExamples() => new Dictionary { [typeof(CreateAppointmentRequest)] = new JsonObject { ["patientId"] = "d4e5f6a7-1234-5678-9abc-def012345678", ["doctorId"] = "a1b2c3d4-5678-9abc-def0-123456789abc", ["scheduledAt"] = "2026-04-15T09:30:00Z", ["durationMinutes"] = 30 } }; } ``` Implementations of `ISchemaExampleProvider` are auto-discovered at startup. ### Internal API exclusion [Section titled “Internal API exclusion”](#internal-api-exclusion) Exclude inter-service endpoints from public documentation: ```csharp app.MapPost("/webhooks/keycloak", HandleKeycloakWebhook) .WithMetadata(new InternalApiAttribute()); ``` ### Document transformers [Section titled “Document transformers”](#document-transformers) The module registers these OpenAPI transformers automatically: | Transformer | Purpose | | -------------------------------------------- | -------------------------------------------------------------- | | `JwtBearerSecuritySchemeTransformer` | Adds Bearer security scheme when JWT is configured | | `OAuth2SecuritySchemeTransformer` | Replaces Bearer with OAuth2 Authorization Code when configured | | `SecurityRequirementOperationTransformer` | Anonymous endpoints override global security | | `ProblemDetailsSchemaDocumentTransformer` | Adds RFC 7807 ProblemDetails schema | | `ProblemDetailsResponseOperationTransformer` | Documents 4xx/5xx Problem Details responses | | `TenantHeaderOperationTransformer` | Documents `X-Tenant-Id` header when enabled | | `InternalApiDocumentTransformer` | Removes `[InternalApi]` endpoints | | `WolverineOpenApiOperationTransformer` | Enhances Wolverine HTTP endpoint documentation | | `SchemaExampleSchemaTransformer` | Applies `ISchemaExampleProvider` examples | ### Configuration reference [Section titled “Configuration reference”](#configuration-reference-2) | Property | Default | Description | | ------------------------- | --------------- | ------------------------------------------------------------- | | `Title` | `"API"` | OpenAPI document and Scalar UI title | | `MajorVersions` | `[1]` | Major version numbers to document | | `Description` | `null` | OpenAPI description (Markdown supported) | | `ContactEmail` | `null` | Contact email in OpenAPI info | | `LogoUrl` | `null` | Logo URL for Scalar sidebar | | `FaviconUrl` | `null` | Favicon for Scalar page | | `EnableInProduction` | `false` | Expose docs in Production | | `EnableTenantHeader` | `false` | Document required tenant header | | `TenantHeaderName` | `"X-Tenant-Id"` | Tenant header name | | `AuthorizationPolicy` | `null` | Policy for doc endpoints (`null` = inherit, `""` = anonymous) | | `OAuth2.AuthorizationUrl` | `null` | OAuth2 authorization endpoint | | `OAuth2.TokenUrl` | `null` | OAuth2 token endpoint | | `OAuth2.ClientId` | `null` | Public OAuth2 client ID (PKCE-capable) | | `OAuth2.EnablePkce` | `true` | Enable PKCE with S256 | | `OAuth2.Scopes` | `["openid"]` | OAuth2 scopes to request | *** ## Granit.ExceptionHandling [Section titled “Granit.ExceptionHandling”](#granitexceptionhandling) Centralized exception-to-HTTP-response pipeline implementing RFC 7807 Problem Details. All exceptions are caught, mapped to status codes via a chain of responsibility, logged at the appropriate level, and serialized as `application/problem+json`. ### Setup [Section titled “Setup”](#setup-3) ```csharp [DependsOn(typeof(GranitExceptionHandlingModule))] public class AppModule : GranitModule { } ``` In `Program.cs` (**must be the first middleware**): ```csharp app.UseGranitExceptionHandling(); // Before routing, authentication, authorization app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); ``` ### Exception handling pipeline [Section titled “Exception handling pipeline”](#exception-handling-pipeline) ``` flowchart TD A[Exception thrown] --> B{OperationCanceledException?} B -->|yes| C[Log Info, return true] B -->|no| D[IExceptionStatusCodeMapper chain] D --> E{5xx?} E -->|yes| F[Log Error] E -->|no| G{499?} G -->|yes| H[Log Info] G -->|no| I[Log Warning] F --> J[Build ProblemDetails] H --> J I --> J J --> K{IUserFriendlyException?} K -->|yes| L[Title = exception.Message] K -->|no| M{ExposeInternalErrorDetails?} M -->|yes| N[Title = message, Detail = stack trace] M -->|no| O["Title = 'An unexpected error occurred.'"] L --> P[Write RFC 7807 response] N --> P O --> P ``` ### IExceptionStatusCodeMapper [Section titled “IExceptionStatusCodeMapper”](#iexceptionstatuscodemapper) Chain of responsibility pattern. Mappers are evaluated in registration order; the first non-null result wins. The `DefaultExceptionStatusCodeMapper` is registered last as a catch-all. **Default mappings:** | Exception | Status code | | -------------------------------- | ----------- | | `EntityNotFoundException` | 404 | | `NotFoundException` | 404 | | `ForbiddenException` | 403 | | `UnauthorizedAccessException` | 403 | | `ValidationException` | 422 | | `IHasValidationErrors` | 422 | | `ConflictException` | 409 | | `BusinessRuleViolationException` | 422 | | `BusinessException` | 400 | | `IHasErrorCode` | 400 | | `NotImplementedException` | 501 | | `OperationCanceledException` | 499 | | `TimeoutException` | 408 | | *(any other)* | 500 | ### Custom mapper [Section titled “Custom mapper”](#custom-mapper) Other Granit modules register their own mappers to extend the chain: ```csharp internal sealed class InvoiceConcurrencyMapper : IExceptionStatusCodeMapper { public int? TryGetStatusCode(Exception exception) => exception switch { InvoiceLockedException => StatusCodes.Status423Locked, _ => null }; } // In module ConfigureServices: services.AddSingleton(); ``` ### ProblemDetails extensions [Section titled “ProblemDetails extensions”](#problemdetails-extensions) The response body includes standard RFC 7807 fields plus Granit-specific extensions: ```json { "status": 422, "title": "Invoice amount must be positive.", "detail": null, "traceId": "abcd1234ef567890", "errorCode": "Invoices:InvalidAmount", "errors": { "Amount": ["Granit:Validation:GreaterThanValidator"] } } ``` | Extension | Source | Present when | | ----------- | ------------------------------------------------------------ | ------------------------------------------- | | `traceId` | `Activity.Current?.TraceId` or `HttpContext.TraceIdentifier` | Always | | `errorCode` | `IHasErrorCode.ErrorCode` | Exception implements `IHasErrorCode` | | `errors` | `IHasValidationErrors.ValidationErrors` | Exception implements `IHasValidationErrors` | Danger Never set `ExposeInternalErrorDetails: true` in production. Internal error messages may contain PHI, SQL fragments, or internal paths (ISO 27001 violation). *** ## Granit.Idempotency [Section titled “Granit.Idempotency”](#granitidempotency) Stripe-style HTTP idempotency middleware backed by Redis. Ensures that retried POST/PUT/PATCH requests produce the same response without re-executing side effects. Uses SHA-256 composite keys and AES-256-CBC encrypted entries. ### Setup [Section titled “Setup”](#setup-4) ```csharp [DependsOn(typeof(GranitIdempotencyModule))] public class AppModule : GranitModule { } ``` ```json { "Idempotency": { "HeaderName": "Idempotency-Key", "KeyPrefix": "idp", "CompletedTtl": "24:00:00", "InProgressTtl": "00:00:30", "ExecutionTimeout": "00:00:25", "MaxBodySizeBytes": 1048576 } } ``` In `Program.cs` (after authentication/authorization): ```csharp app.UseAuthentication(); app.UseAuthorization(); app.UseGranitIdempotency(); // After auth so ICurrentUserService is populated ``` ### Marking endpoints as idempotent [Section titled “Marking endpoints as idempotent”](#marking-endpoints-as-idempotent) ```csharp app.MapPost("/api/v1/invoices", CreateInvoice) .WithMetadata(new IdempotentAttribute { Required = true }); // Optional key — middleware is bypassed when header is absent app.MapPut("/api/v1/invoices/{id}", UpdateInvoice) .WithMetadata(new IdempotentAttribute { Required = false }); // Custom TTL (2 hours instead of default 24h) app.MapPost("/api/v1/payments", ProcessPayment) .WithMetadata(new IdempotentAttribute { CompletedTtlSeconds = 7200 }); ``` ### State machine [Section titled “State machine”](#state-machine) ``` stateDiagram-v2 [*] --> Absent Absent --> InProgress: TryAcquire (SET NX PX) InProgress --> Completed: SetCompleted (SET XX PX) InProgress --> Absent: 5xx / timeout (DELETE) Completed --> Absent: TTL expires Completed --> Completed: Replay cached response ``` ### Composite key structure [Section titled “Composite key structure”](#composite-key-structure) The Redis key is partitioned by tenant, user, HTTP method, and route to prevent cross-user key collisions: ```plaintext {prefix}:{tenantId|global}:{userId|anon}:{method}:{routePattern}:{sha256(idempotencyKeyValue)} ``` **Example:** `idp:global:d4e5f6a7:POST:/api/v1/invoices:a1b2c3d4e5f6...` ### Payload hash verification [Section titled “Payload hash verification”](#payload-hash-verification) The middleware computes a SHA-256 digest of the composite input (method + route + idempotency key value + request body). On replay, if the payload hash does not match the stored entry, the request is rejected with 422 to prevent key reuse with a different body. ### Response caching rules [Section titled “Response caching rules”](#response-caching-rules) | Status code | Cached? | Rationale | | ----------------------- | ------- | --------------------------------- | | 2xx | Yes | Successful responses are replayed | | 400, 404, 409, 410, 422 | Yes | Deterministic client errors | | 401, 403 | No | Authentication state may change | | 5xx | No | Lock is released for retry | | 499 (client disconnect) | No | Response may be truncated | ### Error responses [Section titled “Error responses”](#error-responses) | Scenario | Status | Title | | ----------------------------- | ------ | ------------------------ | | Missing header (required) | 422 | Missing Idempotency-Key | | Multipart request | 422 | Unsupported Content-Type | | Key in progress (another pod) | 409 | Request In Progress | | Payload hash mismatch | 422 | Idempotency Key Conflict | | Execution timeout | 503 | Execution Timeout | Replayed responses include an `X-Idempotency-Replayed: true` header. ### Configuration reference [Section titled “Configuration reference”](#configuration-reference-3) | Property | Default | Description | | ------------------ | ------------------- | --------------------------------------- | | `HeaderName` | `"Idempotency-Key"` | HTTP header name | | `KeyPrefix` | `"idp"` | Redis key prefix | | `CompletedTtl` | `24:00:00` | TTL for completed entries | | `InProgressTtl` | `00:00:30` | Lock TTL (must be > `ExecutionTimeout`) | | `ExecutionTimeout` | `00:00:25` | Max handler execution time | | `MaxBodySizeBytes` | `1048576` | Max request body size to hash (1 MiB) | *** ## Granit.RateLimiting [Section titled “Granit.RateLimiting”](#granitratelimiting) Per-tenant rate limiting with four algorithms, configurable policies, Redis-backed counters (with in-memory fallback), and Wolverine message handler support. Integrates with `Granit.Features` for plan-based dynamic quotas. ### Setup [Section titled “Setup”](#setup-5) ```csharp [DependsOn(typeof(GranitRateLimitingModule))] public class AppModule : GranitModule { } ``` ```json { "RateLimiting": { "Enabled": true, "KeyPrefix": "rl", "BypassRoles": ["admin"], "FallbackOnCounterStoreFailure": "Allow", "Policies": { "api-default": { "Algorithm": "SlidingWindow", "PermitLimit": 1000, "Window": "00:01:00", "SegmentsPerWindow": 6 }, "api-sensitive": { "Algorithm": "TokenBucket", "TokenLimit": 50, "TokensPerPeriod": 10, "ReplenishmentPeriod": "00:00:10" } } } } ``` ### Applying to endpoints [Section titled “Applying to endpoints”](#applying-to-endpoints) ```csharp app.MapGet("/api/v1/appointments", GetAppointments) .RequireGranitRateLimiting("api-default"); app.MapPost("/api/v1/payments", ProcessPayment) .RequireGranitRateLimiting("api-sensitive"); ``` ### Wolverine message handler support [Section titled “Wolverine message handler support”](#wolverine-message-handler-support) Decorate message types with `[RateLimited]` and register the Wolverine middleware: ```csharp [RateLimited("api-default")] public record SyncPatientCommand(Guid PatientId); ``` ```csharp // In Wolverine configuration opts.Policies.AddMiddleware( chain => chain.MessageType.GetCustomAttributes(typeof(RateLimitedAttribute), true).Length > 0); ``` When the rate limit is exceeded, `RateLimitExceededException` is thrown and handled by Wolverine’s retry policy. ### Algorithms [Section titled “Algorithms”](#algorithms) | Algorithm | Use case | Key parameters | | --------------- | ------------------------------------- | ------------------------------------------------------ | | `SlidingWindow` | General API rate limiting (default) | `PermitLimit`, `Window`, `SegmentsPerWindow` | | `FixedWindow` | Simple counter, lowest memory | `PermitLimit`, `Window` | | `TokenBucket` | Controlled burst allowance | `TokenLimit`, `TokensPerPeriod`, `ReplenishmentPeriod` | | `Concurrency` | Limit simultaneous in-flight requests | `PermitLimit` | ### Tenant partitioning [Section titled “Tenant partitioning”](#tenant-partitioning) Rate limit counters are partitioned by tenant. The Redis key uses hash tags to ensure all keys for a tenant hash to the same Redis Cluster slot: ```plaintext {prefix}:{{tenantId|global}}:{policyName} ``` ### Failure behavior [Section titled “Failure behavior”](#failure-behavior) When Redis is unavailable, the `FallbackOnCounterStoreFailure` setting controls behavior: | Value | Behavior | Use case | | ------- | ------------------------------------ | ---------------------------------------------- | | `Allow` | Let the request through, log warning | Prefer availability over quota enforcement | | `Deny` | Reject with 429 | Conservative — prefer safety over availability | ### 429 response format [Section titled “429 response format”](#429-response-format) ```json { "status": 429, "title": "Too Many Requests", "detail": "Rate limit exceeded for policy 'api-default'. Retry after 10s.", "policy": "api-default", "limit": 1000, "remaining": 0, "retryAfter": 10 } ``` The `Retry-After` header is also set on the response. ### Dynamic quotas with Granit.Features [Section titled “Dynamic quotas with Granit.Features”](#dynamic-quotas-with-granitfeatures) When `UseFeatureBasedQuotas` is enabled, the permit limit is resolved dynamically from `Granit.Features` (e.g., per-plan quotas). The convention-based feature name is `RateLimit.{PolicyName}`, overridable via `RateLimitPolicyOptions.FeatureName`. ### Configuration reference [Section titled “Configuration reference”](#configuration-reference-4) | Property | Default | Description | | ------------------------------- | ------- | ---------------------------------------- | | `Enabled` | `true` | Enable/disable rate limiting globally | | `KeyPrefix` | `"rl"` | Redis key prefix | | `FallbackOnCounterStoreFailure` | `Allow` | Behavior when Redis is down | | `BypassRoles` | `[]` | Roles that skip rate limiting | | `UseFeatureBasedQuotas` | `false` | Use `Granit.Features` for dynamic quotas | | `Policies.*` | — | Named rate limiting policies (see below) | **Policy options:** | Property | Default | Description | | --------------------- | --------------- | ------------------------------------------------- | | `Algorithm` | `SlidingWindow` | Rate limiting algorithm | | `PermitLimit` | `1000` | Max permits per window | | `Window` | `00:01:00` | Window duration | | `SegmentsPerWindow` | `6` | Sliding window segments (accuracy vs. memory) | | `TokenLimit` | `50` | Max tokens (TokenBucket only) | | `TokensPerPeriod` | `10` | Tokens added per replenishment (TokenBucket only) | | `ReplenishmentPeriod` | `00:00:10` | Replenishment interval (TokenBucket only) | | `FeatureName` | `null` | Override feature name for dynamic quotas | *** ## Public API summary [Section titled “Public API summary”](#public-api-summary) | Category | Key types | Package | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------- | | Modules | `GranitCorsModule`, `GranitApiVersioningModule`, `GranitApiDocumentationModule`, `GranitExceptionHandlingModule`, `GranitIdempotencyModule`, `GranitRateLimitingModule` | — | | CORS | `GranitCorsOptions` | `Granit.Cors` | | Versioning | `GranitApiVersioningOptions`, `DeprecatedAttribute`, `.Deprecated()` | `Granit.ApiVersioning` | | Documentation | `ApiDocumentationOptions`, `OAuth2Options`, `ISchemaExampleProvider`, `InternalApiAttribute` | `Granit.ApiDocumentation` | | Exception handling | `IExceptionStatusCodeMapper`, `ExceptionHandlingOptions`, `GranitExceptionHandler` | `Granit.ExceptionHandling` | | Idempotency | `IIdempotencyStore`, `IIdempotencyMetadata`, `IdempotentAttribute`, `IdempotencyOptions`, `IdempotencyEntry`, `IdempotencyState` | `Granit.Idempotency` | | Rate limiting | `IRateLimitCounterStore`, `IRateLimitQuotaProvider`, `RateLimitResult`, `RateLimitedAttribute`, `RateLimitExceededException`, `GranitRateLimitingOptions`, `RateLimitPolicyOptions` | `Granit.RateLimiting` | | Extensions | `AddGranitCors()`, `AddGranitApiVersioning()`, `AddGranitApiDocumentation()`, `UseGranitApiDocumentation()`, `AddGranitExceptionHandling()`, `UseGranitExceptionHandling()`, `AddGranitIdempotency()`, `UseGranitIdempotency()`, `AddGranitRateLimiting()`, `.RequireGranitRateLimiting()` | — | ## See also [Section titled “See also”](#see-also) * [Security module](./security/) — JWT Bearer authentication, authorization * [Caching module](./caching/) — `ICacheValueEncryptor` used by idempotency store * [Core module](./core/) — Exception hierarchy (`BusinessException`, `IHasErrorCode`) * [Wolverine module](./wolverine/) — Wolverine messaging pipeline, rate limit middleware * [Utilities module](./utilities/) — Validation, timing, GUID generation * [API Reference](/api/Granit.Cors.html) (auto-generated from XML docs) # Granit.BackgroundJobs > Recurring background job scheduling with cron expressions, CQRS administration, cluster-safe singleton scheduling, atomic Wolverine outbox rescheduling Granit.BackgroundJobs provides a declarative, attribute-driven recurring job system built on [Cronos](https://github.com/HangfireIO/Cronos) cron parsing. Decorate a message class with `[RecurringJob]`, write a handler, and the framework handles scheduling, persistence, pause/resume, manual trigger, failure tracking, and ISO 27001 audit trail. By default, jobs dispatch through in-process `Channel` — add the Wolverine package for durable outbox scheduling with cluster-safe singleton execution. ## Package structure [Section titled “Package structure”](#package-structure) | Package | Role | Depends on | | ------------------------------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------ | | `Granit.BackgroundJobs` | `[RecurringJob]` attribute, `IBackgroundJobReader`/`Writer`, in-memory store, Channel dispatcher | `Granit.Core`, `Granit.Security`, `Granit.Timing`, `Granit.Guids` | | `Granit.BackgroundJobs.EntityFrameworkCore` | `BackgroundJobsDbContext`, `EfBackgroundJobStore` (durable persistence) | `Granit.BackgroundJobs`, `Granit.Persistence` | | `Granit.BackgroundJobs.Endpoints` | 5 Minimal API endpoints with `BackgroundJobs.Jobs.Manage` permission | `Granit.BackgroundJobs`, `Granit.Authorization`, `Granit.Querying` | | `Granit.BackgroundJobs.Wolverine` | `CronSchedulerAgent` (singleton), `RecurringJobSchedulingMiddleware`, DLQ inspector | `Granit.BackgroundJobs`, `Granit.Wolverine` | ## Dependency graph [Section titled “Dependency graph”](#dependency-graph) ``` graph TD BJ[Granit.BackgroundJobs] --> C[Granit.Core] BJ --> S[Granit.Security] BJ --> T[Granit.Timing] BJ --> G[Granit.Guids] EF[Granit.BackgroundJobs.EntityFrameworkCore] --> BJ EF --> P[Granit.Persistence] EP[Granit.BackgroundJobs.Endpoints] --> BJ EP --> A[Granit.Authorization] EP --> Q[Granit.Querying] W[Granit.BackgroundJobs.Wolverine] --> BJ W --> WM[Granit.Wolverine] ``` ## Setup [Section titled “Setup”](#setup) * Development (in-memory) ```csharp [DependsOn(typeof(GranitBackgroundJobsModule))] public class AppModule : GranitModule { } ``` No database required. Jobs are stored in a `ConcurrentDictionary`. State is lost on restart. * Production (EF Core + Wolverine) ```csharp [DependsOn( typeof(GranitBackgroundJobsEntityFrameworkCoreModule), typeof(GranitBackgroundJobsWolverineModule), typeof(GranitBackgroundJobsEndpointsModule))] public class AppModule : GranitModule { public override void ConfigureServices(ServiceConfigurationContext context) { context.Builder.AddGranitBackgroundJobsEntityFrameworkCore( options => options.UseNpgsql( context.Configuration.GetConnectionString("BackgroundJobs"))); } } ``` ```json { "ConnectionStrings": { "BackgroundJobs": "Host=db;Database=myapp;Username=app;Password=..." } } ``` Route registration in `Program.cs`: ```csharp app.MapBackgroundJobsEndpoints(); ``` * EF Core without Wolverine ```csharp [DependsOn(typeof(GranitBackgroundJobsEntityFrameworkCoreModule))] public class AppModule : GranitModule { public override void ConfigureServices(ServiceConfigurationContext context) { context.Builder.AddGranitBackgroundJobsEntityFrameworkCore( options => options.UseNpgsql( context.Configuration.GetConnectionString("BackgroundJobs"))); } } ``` Jobs persist in the database but dispatch uses in-process `Channel`. Scheduling does not survive process restarts and is not cluster-safe. Suitable for single-instance deployments. ## Declaring a recurring job [Section titled “Declaring a recurring job”](#declaring-a-recurring-job) Decorate a plain message class with `[RecurringJob]` and write a Wolverine-convention handler: ```csharp [RecurringJob("0 2 * * *", "nightly-appointment-cleanup")] public sealed class NightlyAppointmentCleanupCommand; public static class NightlyAppointmentCleanupHandler { public static async Task Handle( NightlyAppointmentCleanupCommand command, AppDbContext db, IClock clock, CancellationToken cancellationToken) { DateTimeOffset cutoff = clock.Now.AddDays(-90); await db.Appointments .Where(a => a.Status == AppointmentStatus.Cancelled && a.CancelledAt < cutoff) .ExecuteDeleteAsync(cancellationToken) .ConfigureAwait(false); } } ``` ### RecurringJobAttribute [Section titled “RecurringJobAttribute”](#recurringjobattribute) ```csharp [RecurringJob(string cronExpression, string name)] ``` | Parameter | Description | | ---------------- | -------------------------------------------------------------------------------------------------------------------- | | `cronExpression` | Standard 5-field or 6-field (with seconds) cron expression, parsed by [Cronos](https://github.com/HangfireIO/Cronos) | | `name` | Unique, stable identifier (kebab-case). Used as primary key in the persistent store | Common cron expressions: | Expression | Schedule | | ---------------- | ------------------------------------ | | `0 * * * *` | Every hour at minute 0 | | `0 2 * * *` | Daily at 02:00 UTC | | `0 0 * * 1` | Every Monday at 00:00 UTC | | `*/5 * * * *` | Every 5 minutes | | `0 0 1 * *` | First day of each month at 00:00 UTC | | `0 */30 * * * *` | Every 30 seconds (6-field format) | ## Job discovery and seeding [Section titled “Job discovery and seeding”](#job-discovery-and-seeding) On application startup, `RecurringJobDiscovery` scans the entry assembly (and any additional assemblies passed to `AddGranitBackgroundJobs()`) for types decorated with `[RecurringJob]`. Discovered jobs are seeded into the store via `IBackgroundJobStoreWriter.SeedJobsAsync()`: * **New jobs** are inserted with `IsEnabled = true`. * **Existing jobs** have their `CronExpression` and `MessageType` updated. Administrative state (`IsEnabled`, `TriggeredBy`, failure counters) is preserved across deployments. ## BackgroundJobDefinition entity [Section titled “BackgroundJobDefinition entity”](#backgroundjobdefinition-entity) The persistent administrative record for each recurring job: | Property | Type | Description | | ------------------------- | -------------------- | --------------------------------------------------- | | `Id` | `Guid` | Primary key (sequential GUID) | | `JobName` | `string` (max 200) | Unique identifier matching `[RecurringJob]` name | | `MessageType` | `string` (max 500) | Assembly-qualified CLR type of the message | | `CronExpression` | `string` (max 100) | Cron schedule (5 or 6 fields) | | `IsEnabled` | `bool` | Active state (`false` = paused) | | `LastExecutedAt` | `DateTimeOffset?` | UTC timestamp of last execution start | | `NextExecutionAt` | `DateTimeOffset?` | UTC timestamp of next scheduled execution | | `ConsecutiveFailureCount` | `int` | Failures since last success (reset on success) | | `LastErrorMessage` | `string?` (max 2000) | Error from last failure (`null` on success) | | `TriggeredBy` | `string?` (max 450) | UserId of manual trigger operator (ISO 27001 audit) | EF Core table: `scheduling_background_jobs` with a unique index on `JobName`. ## CQRS reader/writer interfaces [Section titled “CQRS reader/writer interfaces”](#cqrs-readerwriter-interfaces) ### IBackgroundJobReader [Section titled “IBackgroundJobReader”](#ibackgroundjobreader) Read-only monitoring interface returning immutable `BackgroundJobStatus` snapshots: ```csharp public interface IBackgroundJobReader { Task> GetAllAsync( CancellationToken cancellationToken = default); Task FindAsync( string jobName, CancellationToken cancellationToken = default); } ``` `BackgroundJobStatus` includes a `DeadLetterCount` field populated from Wolverine’s DLQ (returns `0` in in-memory mode). ### IBackgroundJobWriter [Section titled “IBackgroundJobWriter”](#ibackgroundjobwriter) Administrative operations with ISO 27001 audit trail: ```csharp public interface IBackgroundJobWriter { Task PauseAsync(string jobName, CancellationToken cancellationToken = default); Task ResumeAsync(string jobName, CancellationToken cancellationToken = default); Task TriggerNowAsync(string jobName, CancellationToken cancellationToken = default); } ``` | Method | Behavior | | ----------------- | ------------------------------------------------------------------------------------------------------------------------ | | `PauseAsync` | Sets `IsEnabled = false`. Current execution completes; rescheduling is skipped. Emits `BackgroundJobPaused` domain event | | `ResumeAsync` | Sets `IsEnabled = true` and immediately schedules the next cron occurrence. Emits `BackgroundJobResumed` domain event | | `TriggerNowAsync` | Dispatches the job message immediately. Propagates caller identity via `X-Triggered-By` header for audit | ### Store-level interfaces [Section titled “Store-level interfaces”](#store-level-interfaces) Low-level persistence (internal use by the framework, not typically consumed by application code): | Interface | Role | | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | `IBackgroundJobStoreReader` | `FindAsync`, `GetEnabledJobsAsync`, `GetAllJobsAsync` | | `IBackgroundJobStoreWriter` | `SeedJobsAsync`, `RecordExecutionStartAsync`, `RecordNextExecutionAsync`, `RecordExecutionFailureAsync`, `SetEnabledAsync`, `SetTriggeredByAsync` | ## Scheduling flow [Section titled “Scheduling flow”](#scheduling-flow) ### Without Wolverine (in-process Channel) [Section titled “Without Wolverine (in-process Channel)”](#without-wolverine-in-process-channel) ``` sequenceDiagram participant Seed as SeedService participant Sched as ChannelCronSchedulerService participant Ch as Channel<T> participant Worker as BackgroundJobWorker participant Handler as Job Handler participant Store as IBackgroundJobStoreWriter Seed->>Store: SeedJobsAsync(registrations) Sched->>Store: GetEnabledJobsAsync() Sched->>Ch: ScheduleAsync(message, nextTime) Note over Sched,Ch: Task.Delay until scheduled time Ch->>Worker: ReadAllAsync() Worker->>Store: RecordExecutionStartAsync() Worker->>Handler: HandleAsync(message, ct) Handler-->>Worker: Success Worker->>Store: RecordNextExecutionAsync() Worker->>Ch: ScheduleAsync(nextMessage, nextTime) ``` The `ChannelCronSchedulerService` is a `BackgroundService` that schedules the first occurrence of each enabled job on startup. After each successful handler execution, `BackgroundJobWorker` computes and schedules the next occurrence. This does **not** survive process restarts and does **not** guarantee singleton execution across nodes. ### With Wolverine (durable outbox) [Section titled “With Wolverine (durable outbox)”](#with-wolverine-durable-outbox) ``` sequenceDiagram participant Agent as CronSchedulerAgent participant Bus as IMessageBus participant Outbox as PostgreSQL Outbox participant MW as RecurringJobSchedulingMiddleware participant Handler as Job Handler participant Store as IBackgroundJobStoreWriter Note over Agent: SingularAgent — one node only Agent->>Store: GetEnabledJobsAsync() Agent->>Bus: ScheduleAsync(message, nextTime) Bus->>Outbox: INSERT scheduled message Note over Outbox: Wolverine dispatches at scheduled time Outbox->>MW: BeforeAsync(envelope) MW->>Store: RecordExecutionStartAsync() MW->>Handler: Handle(message) Handler-->>MW: Success MW->>MW: AfterAsync(envelope, context) MW->>Store: RecordNextExecutionAsync() MW->>Bus: context.ScheduleAsync(nextMessage, nextTime) Note over MW,Bus: Same DB transaction — atomic ``` ## CronSchedulerAgent (cluster-safe singleton) [Section titled “CronSchedulerAgent (cluster-safe singleton)”](#cronscheduleragent-cluster-safe-singleton) When `Granit.BackgroundJobs.Wolverine` is loaded, the `ChannelCronSchedulerService` is replaced by a Wolverine `SingularAgent` (URI: `granit-background-jobs://singleton`). Wolverine guarantees that exactly **one node** in the cluster runs this agent at a time, preventing duplicate scheduling on multi-node startup. **Anti-doublon guarantee:** before scheduling a job, the agent checks whether `BackgroundJobDefinition.NextExecutionAt` is already in the future. If it is, the job is already scheduled in the Outbox — no duplicate message is created. ## RecurringJobSchedulingMiddleware [Section titled “RecurringJobSchedulingMiddleware”](#recurringjobschedulingmiddleware) Wolverine middleware automatically injected onto all handler chains whose message type carries `[RecurringJob]`. Never applied manually. **Before handler execution:** * Records execution start time in the store * Reads the `X-Triggered-By` envelope header and persists it for ISO 27001 audit **After handler execution:** * Computes the next cron occurrence * Calls `context.ScheduleAsync()` inside the **same database transaction** as the handler * Updates `NextExecutionAt` in the store **Atomicity guarantee:** the next scheduled message is inserted in the Outbox within the same transaction as the handler’s business data. If the node crashes before commit, Wolverine redelivers the current message — the “next” message was never inserted, so no duplicate exists. Caution If a job is paused (`IsEnabled = false`), the middleware skips rescheduling after execution. The current execution always completes normally. ## Wolverine-optional pattern (Channel fallback) [Section titled “Wolverine-optional pattern (Channel fallback)”](#wolverine-optional-pattern-channel-fallback) Wolverine is **not** required to use Granit.BackgroundJobs. The core package registers in-process `Channel` implementations by default: | Component | Without Wolverine | With Wolverine | | --------------------------- | --------------------------------------------------- | ----------------------------------------------------------- | | `IBackgroundJobDispatcher` | `ChannelBackgroundJobDispatcher` (in-memory) | `WolverineBackgroundJobDispatcher` (`IMessageBus`) | | Scheduler | `ChannelCronSchedulerService` (`BackgroundService`) | `CronSchedulerAgent` (`SingularAgent`) | | `IDeadLetterQueueInspector` | `NullDeadLetterQueueInspector` (returns `0`) | `WolverineDeadLetterQueueInspector` (`IMessageStore`) | | Rescheduling | `BackgroundJobWorker` (in-process, post-handler) | `RecurringJobSchedulingMiddleware` (atomic, in-transaction) | When `GranitBackgroundJobsWolverineModule` is loaded, it replaces all Channel-based registrations with Wolverine-backed implementations via `ServiceDescriptor.Replace()`. ## Endpoints [Section titled “Endpoints”](#endpoints) Five Minimal API endpoints protected by the `BackgroundJobs.Jobs.Manage` permission: | Method | Route | Handler | Response | | ------ | -------------------------- | --------------------------- | ------------------------------------------------ | | `GET` | `/{prefix}` | List all jobs (paginated) | `200 OK` with `PagedResult` | | `GET` | `/{prefix}/{name}` | Get job by name | `200 OK` / `404 Not Found` | | `POST` | `/{prefix}/{name}/pause` | Pause a job | `204 No Content` / `404 Not Found` | | `POST` | `/{prefix}/{name}/resume` | Resume a paused job | `204 No Content` / `404 Not Found` | | `POST` | `/{prefix}/{name}/trigger` | Trigger immediate execution | `202 Accepted` / `404 Not Found` | Default route prefix: `background-jobs`. Customize via `BackgroundJobsEndpointsOptions`: ```csharp app.MapBackgroundJobsEndpoints(opts => { opts.RoutePrefix = "admin/jobs"; opts.RequiredRole = "ops-team"; opts.TagName = "Job Administration"; }); ``` ### Permission model [Section titled “Permission model”](#permission-model) The `BackgroundJobs.Jobs.Manage` permission grants access to all five endpoints. It integrates with the Granit RBAC pipeline: 1. `AlwaysAllow` bypass (dev/test: `GranitAuthorizationOptions.AlwaysAllow = true`) 2. Admin role bypass (`GranitAuthorizationOptions.AdminRoles`) 3. Permission grant store query per role In production, grant the permission via: * Add the role to `GranitAuthorizationOptions.AdminRoles` * Call `IPermissionManagerWriter.SetAsync("BackgroundJobs.Jobs.Manage", "my-role", tenantId, true)` ## Domain events [Section titled “Domain events”](#domain-events) | Event | Emitted when | | -------------------------------------------------- | ----------------------------------------------------- | | `BackgroundJobPaused(Guid JobId, string JobName)` | Job is paused via `IBackgroundJobWriter.PauseAsync` | | `BackgroundJobResumed(Guid JobId, string JobName)` | Job is resumed via `IBackgroundJobWriter.ResumeAsync` | Both implement `IDomainEvent` and can be handled by Wolverine or in-process subscribers. ## Observability [Section titled “Observability”](#observability) The module registers a `Granit.BackgroundJobs` `ActivitySource` for distributed tracing. Manual triggers create a `backgroundjobs.trigger` span with tags: | Tag | Value | | ----------------------------- | -------------------- | | `backgroundjobs.job_name` | Job name | | `backgroundjobs.triggered_by` | UserId or `"system"` | When `Granit.Observability` is loaded, the activity source is auto-discovered via `GranitActivitySourceRegistry`. ## Configuration reference [Section titled “Configuration reference”](#configuration-reference) ```json { "BackgroundJobs": { "Mode": "Durable", "ConnectionString": "Host=db;Database=myapp;Username=app;Password=..." }, "BackgroundJobsEndpoints": { "RoutePrefix": "background-jobs", "RequiredRole": "granit-background-jobs-admin", "TagName": "Background Jobs" } } ``` | Property | Default | Description | | -------------------------------------- | -------------------------------- | ----------------------------------------- | | `BackgroundJobs.Mode` | `InMemory` | `InMemory` (no DB) or `Durable` (EF Core) | | `BackgroundJobs.ConnectionString` | — | Required when `Mode = Durable` | | `BackgroundJobsEndpoints.RoutePrefix` | `"background-jobs"` | URL prefix for admin endpoints | | `BackgroundJobsEndpoints.RequiredRole` | `"granit-background-jobs-admin"` | Role required for endpoint access | | `BackgroundJobsEndpoints.TagName` | `"Background Jobs"` | OpenAPI tag name | ## Public API summary [Section titled “Public API summary”](#public-api-summary) | Category | Key types | Package | | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | | Module | `GranitBackgroundJobsModule`, `GranitBackgroundJobsEntityFrameworkCoreModule`, `GranitBackgroundJobsEndpointsModule`, `GranitBackgroundJobsWolverineModule` | — | | Attribute | `[RecurringJob]` | `Granit.BackgroundJobs` | | CQRS | `IBackgroundJobReader`, `IBackgroundJobWriter` | `Granit.BackgroundJobs` | | Store | `IBackgroundJobStoreReader`, `IBackgroundJobStoreWriter` | `Granit.BackgroundJobs` | | Domain | `BackgroundJobDefinition`, `BackgroundJobStatus`, `RecurringJobRegistration`, `JobStoreMode` | `Granit.BackgroundJobs` | | Events | `BackgroundJobPaused`, `BackgroundJobResumed` | `Granit.BackgroundJobs` | | Abstractions | `IBackgroundJobDispatcher`, `IDeadLetterQueueInspector` | `Granit.BackgroundJobs` | | Headers | `BackgroundJobHeaders` (`X-Triggered-By`) | `Granit.BackgroundJobs` | | Options | `BackgroundJobsOptions`, `BackgroundJobsEndpointsOptions` | — | | Permissions | `BackgroundJobsPermissions.Jobs.Manage` | `Granit.BackgroundJobs.Endpoints` | | Middleware | `RecurringJobSchedulingMiddleware` | `Granit.BackgroundJobs.Wolverine` | | Extensions | `AddGranitBackgroundJobs()`, `AddGranitBackgroundJobsEntityFrameworkCore()`, `MapBackgroundJobsEndpoints()` | — | ## See also [Section titled “See also”](#see-also) * [Wolverine module](./wolverine/) — Transactional outbox, context propagation, retry policy * [Persistence module](./persistence/) — `ApplyGranitConventions`, isolated DbContext pattern * [Security module](./security/) — `ICurrentUserService` for audit trail propagation * [Core module](./core/) — `IDomainEvent`, `Entity`, module system * [API Reference](/api/Granit.BackgroundJobs.html) (auto-generated from XML docs) # Granit.BlobStorage & Imaging > Multi-provider blob storage (S3, Azure, Google Cloud Storage, FileSystem, Database) with presigned URLs, proxy endpoints, validation pipeline, tenant isolation, image processing (WebP/AVIF, EXIF stripping) Granit.BlobStorage provides a multi-provider blob storage layer with a unified `IBlobStorage` API. Cloud providers (S3, Azure Blob, Google Cloud Storage) use native presigned URLs for Direct-to-Cloud uploads where the application server never touches file bytes. Server-side providers (FileSystem, Database) use `Granit.BlobStorage.Proxy` to expose token-based upload/download endpoints with identical client-side flow. A post-upload validation pipeline (magic bytes, size) ensures integrity before marking blobs as valid. Granit.Imaging adds a fluent image processing pipeline (resize, crop, compress, format conversion, EXIF stripping) powered by Magick.NET. ## Why Direct-to-Cloud? [Section titled “Why Direct-to-Cloud?”](#why-direct-to-cloud) Traditional file upload architectures stream bytes through the application server, which creates several problems at scale: | Problem | Traditional upload | Granit Direct-to-Cloud | | ----------------------- | --------------------------------------------------- | -------------------------------------------------------------- | | **Server memory** | Entire file buffered in RAM | Server handles only metadata (< 1 KB) | | **Bandwidth costs** | Bytes transit through your servers twice (in + out) | Client uploads directly to storage; server bandwidth near zero | | **Horizontal scaling** | Upload throughput limited by server capacity | Unlimited — storage provider handles all I/O | | **Timeout risk** | Large uploads blocked by server timeouts | Upload goes directly to storage with its own timeout window | | **Infrastructure cost** | Need large instances for file processing | Minimal compute — only metadata and validation | For cloud providers (S3, Azure Blob), bytes never touch the application server. For server-side providers (FileSystem, Database), `Granit.BlobStorage.Proxy` streams bytes through thin proxy endpoints without RAM buffering, keeping the architecture consistent while minimizing server resource usage. ## Package structure [Section titled “Package structure”](#package-structure) | Package | Role | Depends on | | ---------------------------------------- | ----------------------------------------------------------- | ------------------------------------------ | | `Granit.BlobStorage` | `IBlobStorage`, `IBlobValidator`, `BlobDescriptor` domain | `Granit.Core` | | `Granit.BlobStorage.S3` | S3 native presigned URLs, `PrefixBlobKeyStrategy` | `Granit.BlobStorage` | | `Granit.BlobStorage.AzureBlob` | Azure Blob SAS tokens, Managed Identity support | `Granit.BlobStorage` | | `Granit.BlobStorage.GoogleCloud` | Google Cloud Storage signed URLs, Workload Identity support | `Granit.BlobStorage` | | `Granit.BlobStorage.FileSystem` | Local file system storage, path traversal protection | `Granit.BlobStorage` | | `Granit.BlobStorage.Database` | EF Core database storage (small files, 10 MB default) | `Granit.BlobStorage`, `Granit.Persistence` | | `Granit.BlobStorage.Proxy` | Token-based proxy endpoints for FileSystem/Database | `Granit.BlobStorage` | | `Granit.BlobStorage.EntityFrameworkCore` | `BlobStorageDbContext`, `EfBlobDescriptorStore` | `Granit.BlobStorage`, `Granit.Persistence` | | `Granit.Imaging` | `IImageProcessor`, `IImagePipeline` fluent API | `Granit.Core` | | `Granit.Imaging.MagickNet` | `MagickNetImageProcessor` (singleton) | `Granit.Imaging` | ## Provider comparison [Section titled “Provider comparison”](#provider-comparison) | Provider | Presigned URLs | Max file size | Use case | | ---------------- | ----------------------- | -------------------- | ----------------------------------- | | **S3** | Native (AWS SigV4) | Unlimited | Production cloud storage | | **Azure Blob** | Native (SAS tokens) | Unlimited | Azure-hosted deployments | | **Google Cloud** | Native (V4 signed URLs) | Unlimited | GCP-hosted deployments | | **FileSystem** | Via Proxy | Disk capacity | Development, on-premise | | **Database** | Via Proxy | 10 MB (configurable) | Small files, regulated environments | Cloud providers (S3, Azure, Google Cloud) generate native presigned/SAS/signed URLs — the application server never touches file bytes. Server-side providers (FileSystem, Database) require `Granit.BlobStorage.Proxy` which exposes ephemeral token-based endpoints that stream bytes through to the underlying storage. The client-side upload/download flow is identical regardless of provider. ## Dependency graph [Section titled “Dependency graph”](#dependency-graph) ``` graph TD BS[Granit.BlobStorage] --> CO[Granit.Core] S3[Granit.BlobStorage.S3] --> BS AZ[Granit.BlobStorage.AzureBlob] --> BS GC[Granit.BlobStorage.GoogleCloud] --> BS FS[Granit.BlobStorage.FileSystem] --> BS DB[Granit.BlobStorage.Database] --> BS DB --> P[Granit.Persistence] PX[Granit.BlobStorage.Proxy] --> BS EF[Granit.BlobStorage.EntityFrameworkCore] --> BS EF --> P IM[Granit.Imaging] --> CO MN[Granit.Imaging.MagickNet] --> IM ``` ## Setup [Section titled “Setup”](#setup) * S3 (production) ```csharp [DependsOn( typeof(GranitBlobStorageEntityFrameworkCoreModule), typeof(GranitImagingMagickNetModule))] public class AppModule : GranitModule { } ``` ```csharp builder.AddGranitBlobStorageS3(); builder.AddGranitBlobStorageEntityFrameworkCore(options => options.UseNpgsql(builder.Configuration.GetConnectionString("BlobStorage"))); ``` ```json { "BlobStorage": { "ServiceUrl": "https://s3.eu-west-1.amazonaws.com", "AccessKey": "", "SecretKey": "", "Region": "eu-west-1", "DefaultBucket": "myapp-blobs", "ForcePathStyle": false, "TenantIsolation": "Prefix", "UploadUrlExpiry": "00:15:00", "DownloadUrlExpiry": "00:05:00" } } ``` * Azure Blob ```csharp [DependsOn( typeof(GranitBlobStorageAzureBlobModule), typeof(GranitBlobStorageEntityFrameworkCoreModule))] public class AppModule : GranitModule { } ``` ```csharp builder.AddGranitBlobStorageAzureBlob(); builder.AddGranitBlobStorageEntityFrameworkCore(options => options.UseNpgsql(builder.Configuration.GetConnectionString("BlobStorage"))); ``` ```json { "BlobStorage": { "ConnectionString": "", "DefaultContainer": "myapp-blobs", "TenantIsolation": "Prefix" } } ``` For Managed Identity (recommended in production), omit `ConnectionString` and set: ```json { "BlobStorage": { "UseManagedIdentity": true, "ServiceUri": "https://myaccount.blob.core.windows.net", "DefaultContainer": "myapp-blobs" } } ``` * Google Cloud ```csharp [DependsOn( typeof(GranitBlobStorageGoogleCloudModule), typeof(GranitBlobStorageEntityFrameworkCoreModule), typeof(GranitImagingMagickNetModule))] public class AppModule : GranitModule { } ``` ```csharp builder.AddGranitBlobStorageGoogleCloud(); builder.AddGranitBlobStorageEntityFrameworkCore(options => options.UseNpgsql(builder.Configuration.GetConnectionString("BlobStorage"))); ``` ```json { "BlobStorage": { "ProjectId": "my-gcp-project", "DefaultBucket": "myapp-blobs", "TenantIsolation": "Prefix", "UploadUrlExpiry": "00:15:00", "DownloadUrlExpiry": "00:05:00" } } ``` For Workload Identity (recommended in GKE), omit `CredentialFilePath` — Application Default Credentials are used automatically. * FileSystem (dev) ```csharp [DependsOn( typeof(GranitBlobStorageFileSystemModule), typeof(GranitBlobStorageProxyModule), typeof(GranitBlobStorageEntityFrameworkCoreModule))] public class AppModule : GranitModule { } ``` ```csharp builder.AddGranitBlobStorageFileSystem(); builder.AddGranitBlobStorageProxy(); builder.AddGranitBlobStorageEntityFrameworkCore(options => options.UseNpgsql(builder.Configuration.GetConnectionString("BlobStorage"))); var app = builder.Build(); app.MapGranitBlobProxyEndpoints(); ``` ```json { "BlobStorage": { "BasePath": "./blobs", "Proxy": { "BaseUrl": "https://api.example.com", "RoutePrefix": "/api/blobs" } } } ``` * Database ```csharp [DependsOn( typeof(GranitBlobStorageDatabaseModule), typeof(GranitBlobStorageProxyModule), typeof(GranitBlobStorageEntityFrameworkCoreModule))] public class AppModule : GranitModule { } ``` ```csharp builder.AddGranitBlobStorageDatabase(options => options.UseNpgsql(builder.Configuration.GetConnectionString("BlobStorage"))); builder.AddGranitBlobStorageProxy(); builder.AddGranitBlobStorageEntityFrameworkCore(options => options.UseNpgsql(builder.Configuration.GetConnectionString("BlobStorage"))); var app = builder.Build(); app.MapGranitBlobProxyEndpoints(); ``` ```json { "BlobStorage": { "MaxBlobSizeBytes": 10485760, "Proxy": { "BaseUrl": "https://api.example.com", "RoutePrefix": "/api/blobs" } } } ``` * MinIO (dev) ```csharp [DependsOn( typeof(GranitBlobStorageEntityFrameworkCoreModule), typeof(GranitImagingMagickNetModule))] public class AppModule : GranitModule { } ``` ```csharp builder.AddGranitBlobStorageS3(); builder.AddGranitBlobStorageEntityFrameworkCore(options => options.UseNpgsql(builder.Configuration.GetConnectionString("BlobStorage"))); ``` ```json { "BlobStorage": { "ServiceUrl": "http://localhost:9000", "AccessKey": "minioadmin", "SecretKey": "minioadmin", "Region": "us-east-1", "DefaultBucket": "dev-blobs", "ForcePathStyle": true } } ``` Caution Never store `AccessKey`, `SecretKey`, or `ConnectionString` in `appsettings.json` for production. Inject credentials from `Granit.Vault` with dynamic rotation. ## Presigned upload flow [Section titled “Presigned upload flow”](#presigned-upload-flow) Cloud providers (S3, Azure) use native presigned URLs. The application server never touches file bytes: ``` sequenceDiagram participant C as Client participant API as Application Server participant ST as Storage (S3 / Azure) participant V as Validation Pipeline C->>API: POST /upload (fileName, contentType, maxBytes) API->>API: Create BlobDescriptor (Pending) API->>ST: Generate presigned/SAS URL ST-->>API: PresignedUploadTicket API-->>C: { blobId, uploadUrl, headers, expiresAt } C->>ST: PUT file (presigned URL + required headers) ST-->>C: 200 OK C->>API: POST /upload/{blobId}/confirm API->>ST: HEAD object (size, metadata) API->>V: Run validators (magic bytes, size) alt All validators pass V-->>API: Valid API->>API: BlobDescriptor → Valid API-->>C: 200 OK else Validation fails V-->>API: Rejected API->>ST: DELETE object API->>API: BlobDescriptor → Rejected API-->>C: 422 (rejection reason) end ``` ### Proxy upload flow (FileSystem / Database) [Section titled “Proxy upload flow (FileSystem / Database)”](#proxy-upload-flow-filesystem--database) Server-side providers use `Granit.BlobStorage.Proxy` which generates ephemeral token-based URLs. The upload is streamed through the proxy endpoint: ``` sequenceDiagram participant C as Client participant API as Application Server participant PX as Proxy Endpoints participant ST as Storage (FS / DB) C->>API: POST /upload (fileName, contentType, maxBytes) API->>API: Create BlobDescriptor (Pending) API->>PX: Generate proxy token PX-->>API: PresignedUploadTicket (token-based URL) API-->>C: { blobId, uploadUrl, headers, expiresAt } C->>PX: PUT /api/blobs/upload/{token} (stream body) PX->>ST: SaveAsync (stream-through, no RAM buffering) ST-->>PX: Saved PX-->>C: 204 No Content ``` Proxy tokens are single-use, TTL-bound, and backed by `IDistributedCache`. ## IBlobStorage [Section titled “IBlobStorage”](#iblobstorage) The primary entry point for blob operations. All operations are scoped to the current tenant resolved via `ICurrentTenant`. ```csharp public interface IBlobStorage { Task InitiateUploadAsync( string containerName, BlobUploadRequest request, CancellationToken cancellationToken = default); Task CreateDownloadUrlAsync( string containerName, Guid blobId, DownloadUrlOptions? options = null, CancellationToken cancellationToken = default); Task GetDescriptorAsync( string containerName, Guid blobId, CancellationToken cancellationToken = default); Task DeleteAsync( string containerName, Guid blobId, string? deletionReason = null, CancellationToken cancellationToken = default); } ``` ### Upload example [Section titled “Upload example”](#upload-example) ```csharp public class PatientPhotoService(IBlobStorage blobStorage) { public async Task InitiatePhotoUploadAsync( string fileName, CancellationToken cancellationToken) { var request = new BlobUploadRequest( FileName: fileName, ContentType: "image/jpeg", MaxAllowedBytes: 10 * 1024 * 1024); // 10 MB return await blobStorage.InitiateUploadAsync( "patient-photos", request, cancellationToken) .ConfigureAwait(false); } } ``` The returned `PresignedUploadTicket` contains everything the frontend needs: | Property | Description | | ----------------- | ----------------------------------------------------------------- | | `BlobId` | Stable identifier for polling status and requesting downloads | | `UploadUrl` | Presigned URL (S3/Azure) or proxy token URL (FileSystem/Database) | | `HttpMethod` | Always `"PUT"` | | `ExpiresAt` | UTC expiry of the presigned URL | | `RequiredHeaders` | Headers the client must include verbatim (e.g. `Content-Type`) | ### Download example [Section titled “Download example”](#download-example) ```csharp public async Task GetPhotoDownloadUrlAsync( Guid blobId, CancellationToken cancellationToken) { return await blobStorage.CreateDownloadUrlAsync( "patient-photos", blobId, new DownloadUrlOptions(DownloadFileName: "patient-photo.jpg"), cancellationToken) .ConfigureAwait(false); } ``` Setting `DownloadFileName` injects a `Content-Disposition: attachment; filename="..."` header into the presigned URL, forcing the browser to download rather than preview. ### GDPR deletion [Section titled “GDPR deletion”](#gdpr-deletion) ```csharp await blobStorage.DeleteAsync( "patient-photos", blobId, deletionReason: "GDPR Art. 17 erasure request", cancellationToken); ``` The storage object is physically deleted (crypto-shredding). The `BlobDescriptor` record is **retained** in the database for the ISO 27001 3-year audit trail. ## BlobDescriptor lifecycle [Section titled “BlobDescriptor lifecycle”](#blobdescriptor-lifecycle) ``` stateDiagram-v2 [*] --> Pending : InitiateUploadAsync Pending --> Uploading : Upload notification Uploading --> Valid : All validators pass Uploading --> Rejected : Validation fails Valid --> Deleted : DeleteAsync (GDPR erasure) ``` | Status | Storage object | DB record | Description | | ----------- | ---------------- | ------------------ | ---------------------------------------- | | `Pending` | Not yet uploaded | Exists | Upload ticket issued, waiting for client | | `Uploading` | Uploaded | Exists | Validation pipeline running | | `Valid` | Present | Exists | Ready for download | | `Rejected` | Deleted | Retained | Magic bytes or size check failed | | `Deleted` | Deleted | Retained (3 years) | GDPR Art. 17 crypto-shredding | ## CQRS persistence [Section titled “CQRS persistence”](#cqrs-persistence) Blob descriptor persistence follows the CQRS pattern with separate reader and writer interfaces. Always inject the specific interface matching your intent: ```csharp // Read-only: querying blob metadata public class BlobQueryHandler(IBlobDescriptorReader reader) { } // Write-only: updating blob state public class BlobCommandHandler(IBlobDescriptorWriter writer) { } ``` `IBlobDescriptorReader` also provides specialized queries for background jobs: | Method | Purpose | | ---------------------------------------------------------- | ------------------------------------------------------- | | `FindAsync(blobId)` | Single descriptor by ID | | `FindOrphanedAsync(cutoff, batchSize)` | Uploads stuck in `Pending`/`Uploading` (orphan cleanup) | | `FindByContainerBeforeAsync(container, cutoff, batchSize)` | GDPR retention cleanup | ## Validation pipeline [Section titled “Validation pipeline”](#validation-pipeline) Validators run **after** the file lands on storage. The `BlobValidationContext` provides partial access (range read for magic bytes, HEAD for metadata) without buffering the full file in the application server. ### Built-in validators [Section titled “Built-in validators”](#built-in-validators) | Validator | Order | Check | | --------------------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | `MagicBytesValidator` | 10 | Reads first 261 bytes via range read; compares magic-byte signature against declared `Content-Type`. Detects PDF, JPEG, PNG, GIF, TIFF, DICOM, ZIP. | | `MaxSizeValidator` | 20 | Compares actual size against `MaxAllowedBytes` declared at upload time. | The pipeline is **fail-fast**: processing stops at the first failing validator. ### Custom validator [Section titled “Custom validator”](#custom-validator) Register application-specific validators (e.g. antivirus scanning) via DI: ```csharp public sealed class AntivirusValidator(IAntivirusClient av) : IBlobValidator { public int Order => 30; public async Task ValidateAsync( BlobValidationContext context, CancellationToken cancellationToken = default) { bool isSafe = await av.ScanAsync( context.Descriptor.ObjectKey, cancellationToken) .ConfigureAwait(false); return isSafe ? BlobValidationResult.Success() : BlobValidationResult.Failure("Malware detected by antivirus scan."); } } ``` ```csharp builder.Services.AddSingleton(); ``` ## Tenant isolation [Section titled “Tenant isolation”](#tenant-isolation) All providers use the same tenant-prefixed object key strategy: ```plaintext {tenantId}/{containerName}/{yyyy}/{MM}/{blobId} ``` The date components distribute keys across a wider key-space prefix, reducing hot-spot partitions on large buckets. | Strategy | Key format | Isolation level | Provider support | | ------------------ | --------------------------------------------- | ------------------------------- | ---------------- | | `Prefix` (default) | `{tenantId}/{container}/{yyyy}/{MM}/{blobId}` | Key-prefix | All providers | | `Container` | `{container}/{yyyy}/{MM}/{blobId}` | One container/bucket per tenant | S3, Azure only | Single-tenant deployments (no `ICurrentTenant`) omit the tenant prefix: `{containerName}/{yyyy}/{MM}/{blobId}`. ### Per-provider isolation details [Section titled “Per-provider isolation details”](#per-provider-isolation-details) | Provider | Isolation mechanism | | ---------------- | ------------------------------------------------------------------------------------- | | **S3** | Tenant prefix in S3 object key. `Bucket` strategy uses one S3 bucket per tenant. | | **Azure Blob** | Tenant prefix in blob name. `Container` strategy uses one Azure container per tenant. | | **Google Cloud** | Tenant prefix in GCS object name. `Bucket` strategy uses one GCS bucket per tenant. | | **FileSystem** | Tenant prefix in directory path: `{BasePath}/{tenantId}/...` | | **Database** | Tenant prefix in `ObjectKey` column + `IMultiTenant` EF Core query filter | ## Domain events [Section titled “Domain events”](#domain-events) `BlobDescriptor` publishes domain events on state transitions: | Event | Trigger | | ---------------------------------------------------------------------- | -------------------------- | | `BlobValidated(BlobId, ContainerName, VerifiedContentType, SizeBytes)` | Blob passes all validators | | `BlobRejected(BlobId, ContainerName, RejectionReason)` | Validation fails | | `BlobDeleted(BlobId, ContainerName, DeletionReason)` | GDPR crypto-shredding | Subscribe via Wolverine handlers to react to blob lifecycle changes (e.g. trigger image processing after validation, notify the user on rejection). ## Image processing (Granit.Imaging) [Section titled “Image processing (Granit.Imaging)”](#image-processing-granitimaging) `IImageProcessor` provides a fluent pipeline for image transformations. The pipeline holds native resources and must be disposed after use. ```csharp public class PatientPhotoProcessor(IImageProcessor imageProcessor) { public async Task CreateThumbnailAsync( Stream sourceImage, CancellationToken cancellationToken) { await using IImagePipeline pipeline = imageProcessor.Load(sourceImage); return await pipeline .Resize(200, 200, ResizeMode.Crop) .StripMetadata() .Compress(quality: 80) .ConvertTo(ImageFormat.WebP) .ToResultAsync(cancellationToken); } } ``` ### Pipeline operations [Section titled “Pipeline operations”](#pipeline-operations) | Method | Description | | ------------------------------------ | ----------------------------------------- | | `Resize(width, height, mode)` | Resize with configurable strategy | | `Crop(rectangle)` | Crop to rectangular region | | `Compress(quality)` | Set output quality (0-100) | | `ConvertTo(format)` | Change output format | | `Watermark(data, position, opacity)` | Composite watermark overlay | | `StripMetadata()` | Remove EXIF, IPTC, XMP metadata (GDPR) | | `ToResultAsync()` | Terminal: encode and return `ImageResult` | | `SaveToStreamAsync(stream)` | Terminal: encode and write to stream | ### Resize modes [Section titled “Resize modes”](#resize-modes) | Mode | Behavior | | --------- | ----------------------------------------------------------------------- | | `Max` | Fit within bounds, preserving aspect ratio (may be smaller than target) | | `Pad` | Fit within bounds with transparent padding to exact dimensions | | `Crop` | Fill exact dimensions by resizing and center-cropping overflow | | `Stretch` | Stretch to exact dimensions (distorts aspect ratio) | | `Min` | Resize to minimum bounds covering target dimensions entirely | ### Convenience methods [Section titled “Convenience methods”](#convenience-methods) ```csharp await pipeline.SaveAsWebPAsync(cancellationToken); // ConvertTo(WebP) + ToResultAsync await pipeline.SaveAsAvifAsync(cancellationToken); // ConvertTo(AVIF) + ToResultAsync await pipeline.SaveAsJpegAsync(cancellationToken); // ConvertTo(JPEG) + ToResultAsync await pipeline.SaveAsPngAsync(cancellationToken); // ConvertTo(PNG) + ToResultAsync ``` ### Supported formats [Section titled “Supported formats”](#supported-formats) | Format | Read | Write | Notes | | ------ | ---- | ----- | ---------------------------------------- | | JPEG | Yes | Yes | Lossy, no transparency | | PNG | Yes | Yes | Lossless, transparency | | WebP | Yes | Yes | Modern lossy/lossless, smaller than JPEG | | AVIF | Yes | Yes | Next-gen, best compression ratio | | GIF | Yes | Yes | 256 colors, animation support | | BMP | Yes | Yes | Uncompressed bitmap | | TIFF | Yes | Yes | Lossless, medical imaging | ### GDPR: metadata stripping [Section titled “GDPR: metadata stripping”](#gdpr-metadata-stripping) `StripMetadata()` removes all EXIF, IPTC, and XMP metadata from images. This is critical for GDPR compliance: uploaded photos often contain GPS coordinates, camera serial numbers, timestamps, and other personally identifiable information. ```csharp await using IImagePipeline pipeline = imageProcessor.Load(uploadedPhoto); ImageResult sanitized = await pipeline .StripMetadata() .SaveAsWebPAsync(cancellationToken); ``` ### Combining with blob storage [Section titled “Combining with blob storage”](#combining-with-blob-storage) A typical pattern: subscribe to `BlobValidated` events to post-process images after upload validation: ```csharp public static class BlobValidatedHandler { public static async Task HandleAsync( BlobValidated @event, IBlobStorage blobStorage, IImageProcessor imageProcessor, CancellationToken cancellationToken) { if (@event.ContainerName != "patient-photos") return; // Download, process, re-upload as thumbnail PresignedDownloadUrl download = await blobStorage .CreateDownloadUrlAsync("patient-photos", @event.BlobId, cancellationToken: cancellationToken) .ConfigureAwait(false); // ... fetch image from download.Url, process with imageProcessor } } ``` ## Health check [Section titled “Health check”](#health-check) ```csharp builder.Services.AddHealthChecks() .AddGranitS3HealthCheck(); ``` Verifies S3 connectivity by listing one object in the default bucket (`ListObjectsV2` with `MaxKeys=1`). Tagged `["readiness", "startup"]`. Returns Unhealthy on access denied or unreachable endpoint. Error messages are sanitized — credentials, bucket names, and endpoint URLs are never exposed. ### Google Cloud Storage [Section titled “Google Cloud Storage”](#google-cloud-storage) ```csharp builder.Services.AddHealthChecks() .AddGranitGoogleCloudStorageHealthCheck(); ``` Verifies GCS connectivity by listing one object in the default bucket. Tagged `["readiness", "startup"]`. Returns Unhealthy on access denied or unreachable endpoint. ## Configuration reference [Section titled “Configuration reference”](#configuration-reference) * S3 ```json { "BlobStorage": { "ServiceUrl": "https://s3.eu-west-1.amazonaws.com", "AccessKey": "", "SecretKey": "", "Region": "eu-west-1", "DefaultBucket": "myapp-blobs", "ForcePathStyle": false, "TenantIsolation": "Prefix", "UploadUrlExpiry": "00:15:00", "DownloadUrlExpiry": "00:05:00" } } ``` | Property | Default | Description | | ----------------- | ------------- | ------------------------------------------------------- | | `ServiceUrl` | — | S3-compatible endpoint URL (required) | | `AccessKey` | — | S3 access key (required, inject from Vault) | | `SecretKey` | — | S3 secret key (required, inject from Vault) | | `Region` | `"us-east-1"` | S3 region for request signing | | `DefaultBucket` | — | S3 bucket name (required) | | `ForcePathStyle` | `true` | Path-style URLs (`host/bucket/key`); required for MinIO | | `TenantIsolation` | `Prefix` | `Prefix` (shared bucket) or `Bucket` (one per tenant) | * Azure Blob ```json { "BlobStorage": { "ConnectionString": "", "DefaultContainer": "myapp-blobs", "TenantIsolation": "Prefix" } } ``` | Property | Default | Description | | -------------------- | -------- | --------------------------------------------------------------- | | `ConnectionString` | — | Azure Storage connection string (from Vault) | | `DefaultContainer` | — | Default blob container name (required) | | `UseManagedIdentity` | `false` | Use Azure Managed Identity instead of connection string | | `ServiceUri` | — | Storage account URI (required when `UseManagedIdentity = true`) | | `TenantIsolation` | `Prefix` | `Prefix` (shared container) or `Container` (one per tenant) | * Google Cloud ```json { "BlobStorage": { "ProjectId": "my-gcp-project", "DefaultBucket": "myapp-blobs", "CredentialFilePath": null, "TenantIsolation": "Prefix" } } ``` | Property | Default | Description | | -------------------- | -------- | ----------------------------------------------------------- | | `ProjectId` | — | GCP project ID (required) | | `DefaultBucket` | — | Default GCS bucket name (required) | | `CredentialFilePath` | `null` | Service account key JSON; ADC when null (Workload Identity) | | `TenantIsolation` | `Prefix` | `Prefix` (shared bucket) or `Bucket` (one per tenant) | * FileSystem ```json { "BlobStorage": { "BasePath": "./blobs" } } ``` | Property | Default | Description | | ---------- | ------- | ------------------------------------------ | | `BasePath` | — | Root directory for blob storage (required) | * Database ```json { "BlobStorage": { "MaxBlobSizeBytes": 10485760 } } ``` | Property | Default | Description | | ------------------ | ------------------ | ------------------------------------------ | | `MaxBlobSizeBytes` | `10485760` (10 MB) | Maximum blob size accepted by the provider | * Proxy ```json { "BlobStorage": { "Proxy": { "BaseUrl": "https://api.example.com", "RoutePrefix": "/api/blobs", "MaxUploadBytes": 104857600 } } } ``` | Property | Default | Description | | ---------------- | -------------------- | --------------------------------------- | | `BaseUrl` | — | Public URL of the API server (required) | | `RoutePrefix` | `"/api/blobs"` | Route prefix for proxy endpoints | | `MaxUploadBytes` | `104857600` (100 MB) | Maximum upload size through proxy | ### Common options (all providers) [Section titled “Common options (all providers)”](#common-options-all-providers) | Property | Default | Description | | ------------------- | ---------- | ---------------------------------------- | | `UploadUrlExpiry` | `00:15:00` | Presigned upload URL / proxy token TTL | | `DownloadUrlExpiry` | `00:05:00` | Presigned download URL / proxy token TTL | ## Public API summary [Section titled “Public API summary”](#public-api-summary) | Category | Key types | Package | | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | | Module | `GranitBlobStorageModule`, `GranitBlobStorageS3Module`, `GranitBlobStorageAzureBlobModule`, `GranitBlobStorageGoogleCloudModule`, `GranitBlobStorageFileSystemModule`, `GranitBlobStorageDatabaseModule`, `GranitBlobStorageProxyModule`, `GranitBlobStorageEntityFrameworkCoreModule` | — | | Storage | `IBlobStorage`, `IBlobKeyStrategy` | `Granit.BlobStorage` | | Persistence (CQRS) | `IBlobDescriptorReader`, `IBlobDescriptorWriter`, `IBlobDescriptorStore` | `Granit.BlobStorage` | | Domain | `BlobDescriptor`, `BlobStatus`, `BlobUploadRequest` | `Granit.BlobStorage` | | Presigned URLs | `PresignedUploadTicket`, `PresignedDownloadUrl`, `DownloadUrlOptions` | `Granit.BlobStorage` | | Validation | `IBlobValidator`, `BlobValidationResult`, `BlobValidationContext`, `MagicBytesValidator`, `MaxSizeValidator` | `Granit.BlobStorage` | | Events | `BlobValidated`, `BlobRejected`, `BlobDeleted` | `Granit.BlobStorage` | | S3 options | `S3BlobOptions`, `BlobTenantIsolation` | `Granit.BlobStorage.S3` | | Azure options | `AzureBlobOptions` | `Granit.BlobStorage.AzureBlob` | | Google Cloud options | `GoogleCloudStorageOptions` | `Granit.BlobStorage.GoogleCloud` | | FileSystem options | `FileSystemBlobOptions` | `Granit.BlobStorage.FileSystem` | | Database options | `DatabaseBlobOptions` | `Granit.BlobStorage.Database` | | Proxy options | `ProxyBlobOptions` | `Granit.BlobStorage.Proxy` | | Imaging | `IImageProcessor`, `IImagePipeline`, `ImageResult`, `ImageFormat`, `ImageSize` | `Granit.Imaging` | | Imaging types | `ResizeMode`, `CropRectangle`, `WatermarkPosition` | `Granit.Imaging` | | Extensions | `AddGranitBlobStorageS3()`, `AddGranitBlobStorageAzureBlob()`, `AddGranitBlobStorageGoogleCloud()`, `AddGranitBlobStorageFileSystem()`, `AddGranitBlobStorageDatabase()`, `AddGranitBlobStorageProxy()`, `AddGranitBlobStorageEntityFrameworkCore()`, `AddGranitImagingMagickNet()`, `MapGranitBlobProxyEndpoints()` | — | ## See also [Section titled “See also”](#see-also) * [Configure blob storage](/guides/configure-blob-storage/) — Step-by-step guide * [Pre-signed URL pattern](/architecture/patterns/pre-signed-url/) — Architectural pattern * [Persistence module](./persistence/) — EF Core interceptors, query filters * [Security module](./security/) — Authorization, tenant isolation * [Privacy module](./privacy/) — GDPR compliance patterns * [Background Jobs module](./background-jobs/) — Orphan cleanup, retention jobs # Granit.Caching > Typed distributed cache with stampede protection, AES-256 encryption, HybridCache (L1+L2), Redis health checks Granit.Caching provides a typed, provider-swappable cache layer. Same `ICacheService` interface whether you’re using in-memory for dev, Redis for production, or HybridCache (L1 memory + L2 Redis) for multi-pod Kubernetes deployments. Built-in stampede protection, AES-256 encryption for GDPR-sensitive data, and convention-based key naming. ## Package structure [Section titled “Package structure”](#package-structure) | Package | Role | Depends on | | ----------------------------------- | --------------------------------------------------- | ---------------------------------------------------- | | `Granit.Caching` | `ICacheService`, stampede protection, encryption | `Granit.Core` | | `Granit.Caching.StackExchangeRedis` | Redis provider, health check | `Granit.Caching` | | `Granit.Caching.Hybrid` | HybridCache L1+L2 | `Granit.Caching.StackExchangeRedis`, `Granit.Timing` | ## Dependency graph [Section titled “Dependency graph”](#dependency-graph) ``` graph TD C[Granit.Caching] --> CO[Granit.Core] R[Granit.Caching.StackExchangeRedis] --> C H[Granit.Caching.Hybrid] --> R H --> T[Granit.Timing] ``` ## Setup [Section titled “Setup”](#setup) * Development (in-memory) ```csharp [DependsOn(typeof(GranitCachingModule))] public class AppModule : GranitModule { } ``` No configuration needed. Uses `MemoryDistributedCache` internally. * Production (Redis) ```csharp [DependsOn(typeof(GranitCachingRedisModule))] public class AppModule : GranitModule { } ``` ```json { "Cache": { "KeyPrefix": "myapp", "Redis": { "Configuration": "redis-service:6379", "InstanceName": "myapp:" } } } ``` * Kubernetes (HybridCache) ```csharp [DependsOn(typeof(GranitCachingHybridModule))] public class AppModule : GranitModule { } ``` ```json { "Cache": { "KeyPrefix": "myapp", "EncryptValues": true, "Encryption": { "Key": "" }, "Redis": { "Configuration": "redis-service:6379", "InstanceName": "myapp:" }, "Hybrid": { "LocalCacheExpiration": "00:00:30" } } } ``` ## ICacheService [Section titled “ICacheService”](#icacheservice) The main abstraction — inject it with your cache item type: ```csharp public class PatientCacheItem { public Guid Id { get; set; } public string FullName { get; set; } = string.Empty; public DateOnly DateOfBirth { get; set; } } public class PatientService( ICacheService cache, AppDbContext db) { public async Task GetAsync( Guid id, CancellationToken cancellationToken) { return await cache.GetOrAddAsync(id, async ct => { var patient = await db.Patients .Where(p => p.Id == id) .Select(p => new PatientCacheItem { Id = p.Id, FullName = $"{p.FirstName} {p.LastName}", DateOfBirth = p.DateOfBirth }) .FirstOrDefaultAsync(ct) .ConfigureAwait(false); return patient!; }, cancellationToken: cancellationToken) .ConfigureAwait(false); } } ``` ### Interface [Section titled “Interface”](#interface) ```csharp public interface ICacheService { Task GetAsync(string key, CancellationToken cancellationToken = default); Task GetOrAddAsync(string key, Func> factory, DistributedCacheEntryOptions? options = null, CancellationToken cancellationToken = default); Task SetAsync(string key, TCacheItem value, DistributedCacheEntryOptions? options = null, CancellationToken cancellationToken = default); Task RemoveAsync(string key, CancellationToken cancellationToken = default); Task RefreshAsync(string key, CancellationToken cancellationToken = default); } ``` `ICacheService` adds overloads accepting `TKey` instead of `string` (converted via `ToString()`). ## Key naming convention [Section titled “Key naming convention”](#key-naming-convention) Cache keys follow the pattern: `{KeyPrefix}:{CacheName}:{userKey}` | Part | Source | Example | | ----------- | ------------------------------------------------------ | -------------- | | `KeyPrefix` | `CachingOptions.KeyPrefix` | `myapp` | | `CacheName` | Type name (strips `CacheItem` suffix) or `[CacheName]` | `Patient` | | `userKey` | Caller-provided | `d4e5f6a7-...` | **Result:** `myapp:Patient:d4e5f6a7-...` Override the convention with `[CacheName]`: ```csharp [CacheName("PatientSummary")] public class PatientSummaryCacheItem { /* ... */ } // Key: myapp:PatientSummary:{userKey} ``` ## Stampede protection [Section titled “Stampede protection”](#stampede-protection) `GetOrAddAsync` guarantees **one factory execution** under concurrent requests: ``` sequenceDiagram participant R1 as Request 1 participant R2 as Request 2 participant C as CacheService participant DB as Database R1->>C: GetOrAddAsync("patient:123") R2->>C: GetOrAddAsync("patient:123") C->>C: Cache miss C->>C: Acquire SemaphoreSlim Note over R2,C: R2 waits (lock held) C->>DB: Execute factory DB-->>C: PatientCacheItem C->>C: Store in cache C->>C: Release lock C-->>R1: PatientCacheItem C->>C: Double-check → cache hit C-->>R2: PatientCacheItem (from cache) ``` SemaphoreSlim instances are stored in a dedicated `IMemoryCache` with `SizeLimit=10,000` and auto-cleanup after 30 seconds. ## Encryption [Section titled “Encryption”](#encryption) AES-256-CBC encryption for GDPR-sensitive cached data. Each encryption operation generates a random IV (16 bytes). ### Per-type control [Section titled “Per-type control”](#per-type-control) ```csharp [CacheEncrypted] // Always encrypt this type public class PatientCacheItem { /* ... */ } [CacheEncrypted(false)] // Never encrypt (override global setting) public class CountryCacheItem { /* ... */ } ``` **Priority:** `[CacheEncrypted]` attribute > `CachingOptions.EncryptValues` global flag. Caution HybridCache does **not** support encryption — `HybridCache` manages L2 serialization internally and provides no byte-level interception point. Use `DistributedCacheService` (Redis without Hybrid) when encryption is required. ## HybridCache (L1 + L2) [Section titled “HybridCache (L1 + L2)”](#hybridcache-l1--l2) Two-tier cache for multi-pod Kubernetes deployments: | Tier | Backend | Latency | Scope | | ---- | ----------------- | ------- | ------------ | | L1 | In-memory per pod | < 1 ms | Pod-local | | L2 | Redis (shared) | \~ 2 ms | Cluster-wide | ```plaintext Pod A: GetOrAddAsync("patient:123") L1 miss → L2 miss → DB query → store L1 (30s) + L2 (1h) Pod B: GetOrAddAsync("patient:123") L1 miss → L2 hit → ~2ms RemoveAsync on Pod A: Clears L2 + L1 on Pod A Pod B L1: expires within LocalCacheExpiration (30s) ``` The `LocalCacheExpiration` setting bounds the stale-data window between pods. ## Redis health check [Section titled “Redis health check”](#redis-health-check) ```csharp builder.Services.AddHealthChecks() .AddGranitRedisHealthCheck(degradedThreshold: TimeSpan.FromMilliseconds(100)); ``` | Latency | Status | Effect | | ----------- | --------- | ------------------------------ | | < 100 ms | Healthy | Normal | | >= 100 ms | Degraded | Pod stays in load balancer | | Unreachable | Unhealthy | Pod removed from load balancer | Tagged `["readiness"]` — integrates with Kubernetes readiness probes. ## Configuration reference [Section titled “Configuration reference”](#configuration-reference) ```json { "Cache": { "KeyPrefix": "myapp", "DefaultAbsoluteExpirationRelativeToNow": "01:00:00", "DefaultSlidingExpiration": "00:20:00", "EncryptValues": false, "Encryption": { "Key": "" }, "Redis": { "IsEnabled": true, "Configuration": "redis-service:6379", "InstanceName": "myapp:" }, "Hybrid": { "LocalCacheExpiration": "00:00:30" } } } ``` | Property | Default | Description | | ---------------------------------------- | ------------------ | ------------------------------------- | | `KeyPrefix` | `"dd"` | Global key prefix | | `DefaultAbsoluteExpirationRelativeToNow` | `01:00:00` | Default absolute TTL | | `DefaultSlidingExpiration` | `00:20:00` | Default sliding TTL | | `EncryptValues` | `false` | Global encryption flag | | `Encryption.Key` | — | AES-256 key (base64, 32 bytes) | | `Redis.IsEnabled` | `true` | Enable/disable Redis module | | `Redis.Configuration` | `"localhost:6379"` | StackExchange.Redis connection string | | `Redis.InstanceName` | `"dd:"` | Redis key prefix (app isolation) | | `Hybrid.LocalCacheExpiration` | `00:00:30` | L1 memory TTL (stale-data window) | ## Public API summary [Section titled “Public API summary”](#public-api-summary) | Category | Key types | Package | | ------------ | ---------------------------------------------------------------------------------------------------------- | ---------------- | | Module | `GranitCachingModule`, `GranitCachingRedisModule`, `GranitCachingHybridModule` | — | | Abstractions | `ICacheService`, `ICacheService`, `ICacheValueEncryptor` | `Granit.Caching` | | Attributes | `[CacheName]`, `[CacheEncrypted]` | `Granit.Caching` | | Options | `CachingOptions`, `CacheEncryptionOptions`, `RedisCachingOptions`, `HybridCachingOptions` | — | | Extensions | `AddGranitCaching()`, `AddGranitCachingRedis()`, `AddGranitCachingHybrid()`, `AddGranitRedisHealthCheck()` | — | ## See also [Section titled “See also”](#see-also) * [Persistence module](./persistence/) — EF Core interceptors, query filters * [Security module](./security/) — Authorization permission cache uses `ICacheService` * [API Reference](/api/Granit.Caching.html) (auto-generated from XML docs) # Granit.Core > Foundation module — module system, domain base types, data filtering, events, exceptions Granit.Core is the foundation of the entire framework. Every Granit module depends on it. It provides the module system (topological loading, DI orchestration), shared domain-driven design base types, GDPR/ISO 27001 data filtering, domain events, and a structured exception hierarchy. **Package:** `Granit.Core` **Dependencies:** None (only `Microsoft.AspNetCore.App` framework reference) **Dependents:** 24 packages (all other Granit modules) ## Package structure [Section titled “Package structure”](#package-structure) Granit.Core is a single package — no providers, no EF Core sub-package. It defines abstractions that other modules implement. ## Setup [Section titled “Setup”](#setup) ### Minimal setup [Section titled “Minimal setup”](#minimal-setup) ```csharp var builder = WebApplication.CreateBuilder(args); // Option 1: Single root module (recommended) builder.AddGranit(); // Option 2: Fluent builder (multi-root) builder.AddGranit(granit => granit .AddModule() .AddModule() .AddModule()); var app = builder.Build(); app.UseGranit(); app.Run(); ``` ### Async variants [Section titled “Async variants”](#async-variants) Both `AddGranit` and `UseGranit` have async counterparts for modules that need async initialization (e.g., fetching secrets from Vault at startup): ```csharp await builder.AddGranitAsync(); var app = builder.Build(); await app.UseGranitAsync(); ``` ## Module system [Section titled “Module system”](#module-system) ### GranitModule [Section titled “GranitModule”](#granitmodule) Every module inherits from `GranitModule` and declares dependencies via `[DependsOn]`: ```csharp [DependsOn(typeof(GranitPersistenceModule))] [DependsOn(typeof(GranitSecurityModule))] public class AppModule : GranitModule { public override void ConfigureServices(ServiceConfigurationContext context) { context.Services.AddScoped(); } public override void OnApplicationInitialization(ApplicationInitializationContext context) { var logger = context.ServiceProvider.GetRequiredService>(); logger.LogInformation("AppModule initialized"); } } ``` ### Lifecycle [Section titled “Lifecycle”](#lifecycle) Modules execute in **topological order** (dependencies first), resolved via Kahn’s algorithm. Circular dependencies throw `InvalidOperationException` at startup. ``` sequenceDiagram participant Host as Host Builder participant ML as ModuleLoader participant M1 as GranitCoreModule participant M2 as GranitPersistenceModule participant M3 as AppModule Host->>ML: AddGranit(AppModule) ML->>ML: Discover [DependsOn] graph ML->>ML: Topological sort (Kahn) ML->>M1: ConfigureServices() ML->>M2: ConfigureServices() ML->>M3: ConfigureServices() Note over Host: builder.Build() ML->>M1: OnApplicationInitialization() ML->>M2: OnApplicationInitialization() ML->>M3: OnApplicationInitialization() ``` Each lifecycle method has sync and async variants. Both are called — sync first, then async. ### ServiceConfigurationContext [Section titled “ServiceConfigurationContext”](#serviceconfigurationcontext) Passed to `ConfigureServices` / `ConfigureServicesAsync`: | Property | Type | Description | | ------------------ | ------------------------------ | ------------------------------------------------ | | `Services` | `IServiceCollection` | DI container | | `Configuration` | `IConfiguration` | appsettings + environment | | `Builder` | `IHostApplicationBuilder` | Full builder access | | `ModuleAssemblies` | `IReadOnlyList` | All loaded module assemblies (topological order) | | `Items` | `IDictionary` | Inter-module state sharing during configuration | ### Conditional modules [Section titled “Conditional modules”](#conditional-modules) Override `IsEnabled` to conditionally skip a module: ```csharp public class GranitNotificationsEmailSmtpModule : GranitModule { public override bool IsEnabled(ServiceConfigurationContext context) { return context.Configuration.GetValue("Granit:Notifications:Email:Smtp:Enabled"); } } ``` ### GranitBuilder (fluent API) [Section titled “GranitBuilder (fluent API)”](#granitbuilder-fluent-api) Alternative to `[DependsOn]` for composing modules in `Program.cs`: ```csharp builder.AddGranit(granit => granit .AddModule() .AddModule() .AddModule()); ``` Modules are deduplicated — adding the same module twice is safe. ## Domain base types [Section titled “Domain base types”](#domain-base-types) ### Entity hierarchy [Section titled “Entity hierarchy”](#entity-hierarchy) Choose the right base class based on your audit requirements: * Entity Simplest — just an `Id`. Use for lookup tables and value-like records. ```csharp public class Country : Entity { public string Code { get; set; } = string.Empty; public string Name { get; set; } = string.Empty; } ``` * CreationAuditedEntity Tracks who created the record and when. ```csharp public class Appointment : CreationAuditedEntity { public Guid PatientId { get; set; } public Guid DoctorId { get; set; } public DateTimeOffset ScheduledAt { get; set; } } // Adds: CreatedAt, CreatedBy ``` * AuditedEntity Tracks creation and last modification. ```csharp public class Invoice : AuditedEntity { public string Number { get; set; } = string.Empty; public decimal Amount { get; set; } } // Adds: CreatedAt, CreatedBy, ModifiedAt, ModifiedBy ``` * FullAuditedEntity Full audit trail with soft delete. Implements `ISoftDeletable`. ```csharp public class Patient : FullAuditedEntity { public string FirstName { get; set; } = string.Empty; public string LastName { get; set; } = string.Empty; public DateOnly DateOfBirth { get; set; } } // Adds: CreatedAt, CreatedBy, ModifiedAt, ModifiedBy, // IsDeleted, DeletedAt, DeletedBy ``` ### Aggregate root hierarchy [Section titled “Aggregate root hierarchy”](#aggregate-root-hierarchy) Aggregate roots mirror the entity hierarchy but add domain event support via `IDomainEventSource`: | Base class | Audit level | Domain events | | ------------------------------ | ----------------------- | ------------- | | `AggregateRoot` | None (just `Id`) | Yes | | `CreationAuditedAggregateRoot` | Creation | Yes | | `AuditedAggregateRoot` | Creation + modification | Yes | | `FullAuditedAggregateRoot` | Full + soft delete | Yes | ```csharp public class LegalAgreement : FullAuditedAggregateRoot { public string Title { get; set; } = string.Empty; public int Version { get; set; } public void Sign(string signedBy) { // Business logic... AddDomainEvent(new AgreementSignedEvent(Id, signedBy)); } } ``` ### ValueObject [Section titled “ValueObject”](#valueobject) Equality by value, not by reference. Override `GetEqualityComponents()`: ```csharp public class Address : ValueObject { public string Street { get; init; } = string.Empty; public string City { get; init; } = string.Empty; public string PostalCode { get; init; } = string.Empty; public string Country { get; init; } = string.Empty; protected override IEnumerable GetEqualityComponents() { yield return Street; yield return City; yield return PostalCode; yield return Country; } } ``` ## Data filtering [Section titled “Data filtering”](#data-filtering) Five marker interfaces enable automatic EF Core global query filters. Entities implementing these interfaces are filtered transparently — no manual `WHERE` clauses needed. | Interface | Filter behavior | GDPR/ISO relevance | | ------------------------- | ------------------------------------- | ------------------------------ | | `ISoftDeletable` | Hides `IsDeleted = true` | Right to erasure (Art. 17) | | `IMultiTenant` | Scopes to current `TenantId` | Tenant isolation | | `IActive` | Hides `IsActive = false` | Data minimization | | `IPublishable` | Hides `IsPublished = false` | Draft/published lifecycle | | `IProcessingRestrictable` | Hides `IsProcessingRestricted = true` | Right to restriction (Art. 18) | ### IDataFilter [Section titled “IDataFilter”](#idatafilter) Selectively disable filters at runtime using `IDataFilter`: ```csharp public class PatientPurgeService( IDataFilter dataFilter, AppDbContext dbContext) { public async Task PurgeSoftDeletedAsync(CancellationToken cancellationToken) { using (dataFilter.Disable()) { var deleted = await dbContext.Patients .Where(p => p.IsDeleted && p.DeletedAt < DateTimeOffset.UtcNow.AddYears(-3)) .ToListAsync(cancellationToken) .ConfigureAwait(false); dbContext.Patients.RemoveRange(deleted); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } // Filter automatically re-enabled here } } ``` `DataFilter` is registered as Singleton. It uses `AsyncLocal>` internally — thread-safe, supports nested scopes with automatic restoration. Caution Disabling `IMultiTenant` exposes data from ALL tenants. Only do this in background jobs or admin operations with explicit authorization checks. ### Additional domain interfaces [Section titled “Additional domain interfaces”](#additional-domain-interfaces) | Interface | Properties | Purpose | | ------------------ | ------------------------- | ----------------------------------------------------------------------------------- | | `IVersioned` | `BusinessId`, `Version` | Versioned audit history (stable ID across versions) | | `ITranslatable` | `Translations` collection | Multi-language entity content | | `ITranslation` | `ParentId`, `Culture` | Translation record (with `Translation` and `AuditedTranslation` base classes) | Translation resolution chain (`GetTranslation()` extension): exact culture → parent culture (`fr-BE` → `fr`) → default culture (`en`) → first available. ## Multi-tenancy abstraction [Section titled “Multi-tenancy abstraction”](#multi-tenancy-abstraction) `ICurrentTenant` lives in `Granit.Core.MultiTenancy` — available in every module without referencing `Granit.MultiTenancy`. ```csharp public interface ICurrentTenant { bool IsAvailable { get; } Guid? Id { get; } string? Name { get; } IDisposable Change(Guid? id, string? name = null); } ``` By default, `NullTenantContext` is registered (`IsAvailable = false`). When `Granit.MultiTenancy` is installed, it replaces the registration with a real implementation. ## Domain events [Section titled “Domain events”](#domain-events) | Type | Interface | Convention | Delivery | | ----------------- | ------------------- | ------------------------------------------------- | ------------------------- | | Domain event | `IDomainEvent` | `XxxOccurred` (e.g., `PatientDischargedOccurred`) | In-process, transactional | | Integration event | `IIntegrationEvent` | `XxxEvent` (e.g., `BedReleasedEvent`) | Cross-module, durable | Domain events are collected on aggregate roots via `AddDomainEvent()` and dispatched by `IDomainEventDispatcher`. The default implementation is `NullDomainEventDispatcher` (no-op). `Granit.Wolverine` replaces it with a real dispatcher that routes events through the Wolverine message bus. ## Exception hierarchy [Section titled “Exception hierarchy”](#exception-hierarchy) All exceptions map to specific HTTP status codes via `Granit.ExceptionHandling` (RFC 7807 Problem Details): | Exception | Status | Interfaces | Use case | | -------------------------------- | ------ | ------------------------------------------------ | ------------------------------ | | `BusinessException` | 400 | `IHasErrorCode`, `IUserFriendlyException` | Business rule violation | | `BusinessRuleViolationException` | 422 | (inherits from `BusinessException`) | Semantic validation failure | | `ValidationException` | 422 | `IHasValidationErrors`, `IUserFriendlyException` | FluentValidation errors | | `EntityNotFoundException` | 404 | `IUserFriendlyException` | Entity lookup miss | | `NotFoundException` | 404 | `IUserFriendlyException` | General “not found” | | `ConflictException` | 409 | `IHasErrorCode`, `IUserFriendlyException` | Concurrency/duplicate conflict | | `ForbiddenException` | 403 | `IUserFriendlyException` | Authorization denial | Only exceptions implementing `IUserFriendlyException` expose their message to clients. All others return a generic error message (ISO 27001 — no internal details leak). ```csharp // Business rule with error code throw new BusinessException("Appointment:PastDate", "Cannot schedule in the past"); // Entity not found (generic message to client, details in logs) throw new EntityNotFoundException(typeof(Patient), patientId); // Validation errors (from FluentValidation) throw new ValidationException(new Dictionary { ["Email"] = ["Email address is required"], ["DateOfBirth"] = ["Must be in the past"] }); ``` ## Module configuration endpoint [Section titled “Module configuration endpoint”](#module-configuration-endpoint) Expose read-only module configuration to frontend clients: ```csharp // 1. Define the response DTO public record NotificationConfigResponse(bool EmailEnabled, bool SmsEnabled, int MaxRetries); // 2. Implement the provider public class NotificationConfigProvider(IOptions options) : IModuleConfigProvider { public NotificationConfigResponse GetConfig() => new( options.Value.EmailEnabled, options.Value.SmsEnabled, options.Value.MaxRetries); } // 3. Map the endpoint (in your module's OnApplicationInitialization) app.MapGranitModuleConfig( routePrefix: "api/notifications", endpointName: "GetNotificationConfig", tag: "Notifications"); ``` This maps `GET /api/notifications/config` → 200 OK with the response DTO. ## Batch operations [Section titled “Batch operations”](#batch-operations) `BatchResult` and `BatchResultHelper` standardize partial-success responses: ```csharp var results = new List>(); foreach (var command in commands) { try { var patient = await CreatePatientAsync(command, cancellationToken).ConfigureAwait(false); results.Add(BatchResultHelper.Success(patient)); } catch (ValidationException ex) { results.Add(BatchResultHelper.Failure(ex.Message)); } } var batch = BatchResultHelper.Create(results); return batch.ToResult(); // 200 if all succeeded, 207 Multi-Status if any failed ``` ## Diagnostics [Section titled “Diagnostics”](#diagnostics) `GranitActivitySourceRegistry` is a process-global registry for `System.Diagnostics.ActivitySource` names. Modules call `Register("Granit.ModuleName")` during `ConfigureServices`, and `Granit.Observability` auto-discovers all registered sources at startup for OpenTelemetry tracing. ## Public API summary [Section titled “Public API summary”](#public-api-summary) | Category | Types | Count | | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | | Module system | `GranitModule`, `GranitBuilder`, `DependsOnAttribute`, `ServiceConfigurationContext`, `ApplicationInitializationContext`, `GranitApplication`, `IModuleConfigProvider`, `ModuleDescriptor` | 8 | | Domain base classes | `Entity`, `ValueObject`, `AggregateRoot` + Creation/Audited/FullAudited variants | 10 | | Filter interfaces | `ISoftDeletable`, `IMultiTenant`, `IActive`, `IPublishable`, `IProcessingRestrictable` | 5 | | Data filtering | `IDataFilter`, `DataFilter` | 2 | | Events | `IDomainEvent`, `IIntegrationEvent`, `IDomainEventSource`, `IDomainEventDispatcher` | 4 | | Multi-tenancy | `ICurrentTenant`, `AllowAnonymousTenantAttribute` | 2 | | Exceptions | `BusinessException`, `ValidationException`, `EntityNotFoundException`, `NotFoundException`, `ConflictException`, `ForbiddenException`, `BusinessRuleViolationException` | 7 | | Exception interfaces | `IUserFriendlyException`, `IHasErrorCode`, `IHasValidationErrors` | 3 | | Translation | `ITranslatable`, `ITranslation`, `Translation`, `AuditedTranslation` | 4 | | Other | `IVersioned`, `AuditLogEntry`, `LocalizableString`, `GranitActivitySourceRegistry`, `BatchResult` | 5 | ## See also [Section titled “See also”](#see-also) * [Module system concept](../../concepts/module-system/) * [Persistence](./persistence/) — EF Core interceptors that implement audit and soft delete * [Security](./security/) — `ICurrentUserService` that populates `CreatedBy` / `ModifiedBy` * [Multi-tenancy](./multi-tenancy/) — Full tenant isolation implementation * [API Reference](/api/Granit.Core.html) (auto-generated from XML docs) # Granit.DataExchange > Tabular data import (CSV/Excel) with mapping pipeline, export with presets, background job integration Granit.DataExchange provides a complete import/export pipeline for tabular data. Imports follow a guided flow: upload a file, preview headers, receive intelligent column mapping suggestions, confirm, then execute with batched persistence and detailed error reporting. Exports use a whitelist-based field definition, optional presets, and automatic background dispatch for large datasets. Both pipelines integrate with Wolverine for durable outbox-backed execution when installed. ## Package structure [Section titled “Package structure”](#package-structure) | Package | Role | Depends on | | ----------------------------------------- | ---------------------------------------------------------------- | --------------------------------------------- | | `Granit.DataExchange` | Import/export pipelines, mapping suggestions, fluent definitions | `Granit.Timing`, `Granit.Validation` | | `Granit.DataExchange.Csv` | Sep-based CSV parser, semicolon CSV writer | `Granit.DataExchange` | | `Granit.DataExchange.Excel` | Sylvan streaming Excel reader, ClosedXML writer | `Granit.DataExchange` | | `Granit.DataExchange.EntityFrameworkCore` | DataExchangeDbContext, EF executor, identity resolvers | `Granit.DataExchange`, `Granit.Persistence` | | `Granit.DataExchange.Endpoints` | 19 REST endpoints (import + export + metadata) | `Granit.DataExchange`, `Granit.Authorization` | | `Granit.DataExchange.Wolverine` | Outbox-backed import/export dispatch | `Granit.DataExchange`, `Granit.Wolverine` | ## Dependency graph [Section titled “Dependency graph”](#dependency-graph) ``` graph TD DX[Granit.DataExchange] --> T[Granit.Timing] DX --> V[Granit.Validation] CSV[Granit.DataExchange.Csv] --> DX XLS[Granit.DataExchange.Excel] --> DX EF[Granit.DataExchange.EntityFrameworkCore] --> DX EF --> P[Granit.Persistence] EP[Granit.DataExchange.Endpoints] --> DX EP --> A[Granit.Authorization] WV[Granit.DataExchange.Wolverine] --> DX WV --> W[Granit.Wolverine] ``` ## Setup [Section titled “Setup”](#setup) * Full stack (production) ```csharp [DependsOn( typeof(GranitDataExchangeEntityFrameworkCoreModule), typeof(GranitDataExchangeEndpointsModule), typeof(GranitDataExchangeCsvModule), typeof(GranitDataExchangeExcelModule), typeof(GranitDataExchangeWolverineModule))] public class AppModule : GranitModule { public override void ConfigureServices(ServiceConfigurationContext context) { // Register import definitions context.Services.AddImportDefinition(); // Register export definitions context.Services.AddExportDefinition(); } } ``` ```csharp // Map endpoints in Program.cs app.MapDataExchangeEndpoints(); // Or with custom prefix and role app.MapDataExchangeEndpoints(opts => { opts.RoutePrefix = "admin/data-exchange"; opts.RequiredRole = "ops-team"; }); ``` * Minimal (import only, no persistence) ```csharp [DependsOn( typeof(GranitDataExchangeCsvModule), typeof(GranitDataExchangeExcelModule))] public class AppModule : GranitModule { public override void ConfigureServices(ServiceConfigurationContext context) { context.Services.AddImportDefinition(); } } ``` No EF Core, no endpoints. Useful for CLI tools or custom orchestration. ## Configuration [Section titled “Configuration”](#configuration) * Import options ```json { "DataExchange": { "DefaultMaxFileSizeMb": 50, "DefaultBatchSize": 500, "FuzzyMatchThreshold": 0.8 } } ``` | Property | Default | Description | | ---------------------- | ------- | --------------------------------------------------------- | | `DefaultMaxFileSizeMb` | `50` | Max upload size (overridable per definition) | | `DefaultBatchSize` | `500` | Rows per `SaveChanges` batch | | `FuzzyMatchThreshold` | `0.8` | Minimum Levenshtein similarity for fuzzy tier (0.0 - 1.0) | * Export options ```json { "DataExport": { "BackgroundThreshold": 1000 } } ``` | Property | Default | Description | | --------------------- | ------- | ----------------------------------------------------------- | | `BackgroundThreshold` | `1000` | Row count above which export dispatches to a background job | ## Import pipeline [Section titled “Import pipeline”](#import-pipeline) ### Pipeline overview [Section titled “Pipeline overview”](#pipeline-overview) ``` flowchart LR A[Upload file] --> B[Extract headers] B --> C[Preview rows] C --> D["Suggest mappings
4-tier"] D --> E["User confirms
mappings"] E --> F[Parse rows] F --> G[Map to entities] G --> H[Validate rows] H --> I[Resolve identity] I --> J["Execute batch
INSERT / UPDATE"] J --> K[Report + correction file] ``` ### Import definition [Section titled “Import definition”](#import-definition) Each entity requires an `ImportDefinition` that declares importable properties using a fluent API. Only explicitly declared properties are available for column mapping (whitelist pattern). ```csharp public sealed class PatientImportDefinition : ImportDefinition { public override string Name => "Acme.PatientImport"; protected override void Configure(ImportDefinitionBuilder builder) { builder .HasBusinessKey(p => p.Niss) .Property(p => p.Niss, p => p.DisplayName("NISS").Required()) .Property(p => p.LastName, p => p.DisplayName("Last name").Required()) .Property(p => p.FirstName, p => p.DisplayName("First name").Required()) .Property(p => p.Email, p => p .DisplayName("Email") .Aliases("Courriel", "E-mail", "Mail")) .Property(p => p.BirthDate, p => p .DisplayName("Date of birth") .Format("dd/MM/yyyy")) .ExcludeOnUpdate(p => p.Niss); } } ``` **Property configuration options:** | Method | Description | | --------------------------- | --------------------------------------------------------------------- | | `.DisplayName(string)` | User-facing label (used in preview UI and mapping suggestions) | | `.Description(string)` | Sent to the AI mapping service as field metadata | | `.Aliases(params string[])` | Alternative names for exact and fuzzy matching | | `.Required(bool)` | Import-level required validation (independent of entity `[Required]`) | | `.Format(string)` | Expected format for type conversion (e.g. `"dd/MM/yyyy"`) | **Identity resolution:** | Method | Description | | -------------------------------------------- | ---------------------------------------------------- | | `.HasBusinessKey(p => p.Niss)` | Single natural key for INSERT vs UPDATE resolution | | `.HasCompositeKey(p => p.Code, p => p.Year)` | Multi-column business key | | `.HasExternalId()` | External ID column for cross-system identity mapping | **Parent/child import:** ```csharp builder .GroupBy("InvoiceNumber") .Property(p => p.InvoiceNumber, p => p.Required()) .Property(p => p.CustomerName) .HasMany(p => p.Lines, child => { child.Property(l => l.ProductCode, p => p.Required()); child.Property(l => l.Quantity); child.Property(l => l.UnitPrice); }); ``` ### 4-tier mapping suggestions [Section titled “4-tier mapping suggestions”](#4-tier-mapping-suggestions) When headers are extracted from the uploaded file, the mapping suggestion service runs four tiers in order. Columns matched by a higher-confidence tier are excluded from lower tiers: ``` flowchart TD H[Source column headers] --> T1 T1["Tier 1: Saved mappings
Previously confirmed by user"] --> T2 T2["Tier 2: Exact match
Property name, display name, aliases"] --> T3 T3["Tier 3: Fuzzy match
Levenshtein distance >= threshold"] --> T4 T4["Tier 4: Semantic / AI
Header metadata only, GDPR-safe"] --> R[Suggested mappings] ``` | Tier | Confidence | Source | | -------- | ---------------------------- | ----------------------------------------------------------------- | | Saved | `MappingConfidence.Saved` | Previously confirmed mappings stored in database | | Exact | `MappingConfidence.Exact` | Case-insensitive match on property name, display name, or aliases | | Fuzzy | `MappingConfidence.Fuzzy` | Levenshtein similarity above `FuzzyMatchThreshold` | | Semantic | `MappingConfidence.Semantic` | AI-backed service (opt-in, only header metadata sent) | ### Import job lifecycle [Section titled “Import job lifecycle”](#import-job-lifecycle) | Status | Description | | -------------------- | ------------------------------------------------------------ | | `Created` | File uploaded, job created | | `Previewed` | Headers extracted, preview and mapping suggestions generated | | `Mapped` | Column mappings confirmed by the user | | `Executing` | Import running (background handler) | | `Completed` | All rows imported successfully | | `PartiallyCompleted` | Some rows failed, others succeeded | | `Failed` | Import failed entirely | | `Cancelled` | Cancelled by the user | ### Execution options [Section titled “Execution options”](#execution-options) ```csharp new ImportExecutionOptions { BatchSize = 500, // Rows per SaveChanges batch DryRun = true, // Full pipeline with transaction rollback ErrorBehavior = ImportErrorBehavior.SkipErrors, } ``` | Error behavior | Description | | -------------- | ----------------------------------------------------- | | `FailFast` | Stop immediately on the first error | | `SkipErrors` | Skip errored rows, continue processing (default) | | `CollectAll` | Process all rows, collect all errors without stopping | ### Correction file [Section titled “Correction file”](#correction-file) After an import with `SkipErrors` or `CollectAll`, a downloadable CSV correction file is generated. It contains only the failed rows with an additional error message column. Users can fix the rows and re-upload the corrected file. ## Export pipeline [Section titled “Export pipeline”](#export-pipeline) ### Pipeline overview [Section titled “Pipeline overview”](#pipeline-overview-1) ``` flowchart LR A[Request export] --> B{Row count?} B -- "threshold or less" --> C[Synchronous export] B -- ">threshold" --> D[Background job] C --> E[Query data source] D --> E E --> F[Project fields] F --> G[Write CSV / Excel] G --> H[Store blob] H --> I[Download link] ``` ### Export definition [Section titled “Export definition”](#export-definition) Each entity requires an `ExportDefinition` with a field whitelist. Only declared fields can appear in the output: ```csharp public sealed class PatientExportDefinition : ExportDefinition { public override string Name => "Acme.PatientExport"; public override string? QueryDefinitionName => "Acme.Patients"; protected override void Configure(ExportDefinitionBuilder builder) { builder .IncludeBusinessKey() .Field(p => p.LastName, f => f.Header("Last name")) .Field(p => p.FirstName, f => f.Header("First name")) .Field(p => p.Email) .Field(p => p.BirthDate, f => f .Header("Date of birth") .Format("dd/MM/yyyy")) .Field(p => p.Company, c => c.Name, f => f.Header("Company")); } } ``` **Field configuration options:** | Method | Description | | ----------------- | -------------------------------------------------- | | `.Header(string)` | Column header name in the exported file | | `.Format(string)` | Display format (e.g. `"dd/MM/yyyy"`, `"#,##0.00"`) | | `.Order(int)` | Column order (lower values first) | **Definition-level options:** | Method | Description | | ----------------------- | ---------------------------------------------------------------- | | `.IncludeId()` | Include entity `Id` column for roundtrip import compatibility | | `.IncludeBusinessKey()` | Include business key columns from the matching import definition | **Navigation fields** use a two-argument `Field()` overload for dot-notation traversal. The developer must ensure the corresponding `Include()` is present in the `IExportDataSource` implementation. ### Export presets [Section titled “Export presets”](#export-presets) Presets are named field selections that users can save and reuse. They are stored in the database via `IExportPresetReader` / `IExportPresetWriter`. The REST API exposes CRUD operations under `/metadata/presets/`. ### Export job lifecycle [Section titled “Export job lifecycle”](#export-job-lifecycle) | Status | Description | | ----------- | ----------------------------------------------- | | `Queued` | Job created and queued for background execution | | `Exporting` | Export currently being generated | | `Completed` | File available for download | | `Failed` | Export failed | ### Query integration [Section titled “Query integration”](#query-integration) When `QueryDefinitionName` is set on an export definition, the export pipeline delegates filtering and sorting to `IQueryEngine` from `Granit.Querying`. This reuses the same whitelist-based filtering pipeline as the grid view — the user’s active filters are applied to the export. ## File format support [Section titled “File format support”](#file-format-support) | Format | Parser (import) | Writer (export) | Package | | ------------- | ------------------------------------------------------------------------------- | --------------------------------------------------- | --------------------------- | | CSV | [Sep](https://github.com/nietras/Sep) (SIMD-accelerated) | Semicolon separator (EU locale) | `Granit.DataExchange.Csv` | | Excel (.xlsx) | [Sylvan.Data.Excel](https://github.com/MarkPflug/Sylvan.Data.Excel) (streaming) | [ClosedXML](https://github.com/ClosedXML/ClosedXML) | `Granit.DataExchange.Excel` | Caution At least one parser package must be registered. The core module provides **no** file parser by default. If neither CSV nor Excel is installed, upload will fail at runtime. ## REST endpoints [Section titled “REST endpoints”](#rest-endpoints) All endpoints require authorization. Import endpoints use the `DataExchange.Imports.Execute` permission, export endpoints use `DataExchange.Exports.Execute`. ### Import endpoints [Section titled “Import endpoints”](#import-endpoints) | Method | Path | Description | | -------- | -------------------------- | ----------------------------------------------------- | | `GET` | `/jobs` | List import jobs | | `POST` | `/` | Upload file (creates import job) | | `POST` | `/{jobId}/preview` | Extract headers and generate mapping suggestions | | `PUT` | `/{jobId}/mappings` | Confirm column mappings | | `POST` | `/{jobId}/execute` | Execute the import | | `POST` | `/{jobId}/dry-run` | Full pipeline with transaction rollback | | `GET` | `/{jobId}` | Get import job status | | `DELETE` | `/{jobId}` | Cancel import job | | `GET` | `/{jobId}/report` | Get import report (success/error counts, row details) | | `GET` | `/{jobId}/correction-file` | Download CSV with failed rows and error messages | ### Export endpoints [Section titled “Export endpoints”](#export-endpoints) | Method | Path | Description | | ------ | ---------------------------- | ------------------------- | | `GET` | `/export/jobs` | List export jobs | | `POST` | `/export/jobs` | Create and execute export | | `GET` | `/export/jobs/{id}` | Get export job status | | `GET` | `/export/jobs/{id}/download` | Download exported file | ### Metadata endpoints [Section titled “Metadata endpoints”](#metadata-endpoints) | Method | Path | Description | | -------- | ------------------------------------------------- | -------------------------------------- | | `GET` | `/metadata/definitions` | List registered export definitions | | `GET` | `/metadata/definitions/{name}/fields` | List available fields for a definition | | `GET` | `/metadata/presets/{definitionName}` | List saved presets for a definition | | `POST` | `/metadata/presets` | Save a field selection preset | | `DELETE` | `/metadata/presets/{definitionName}/{presetName}` | Delete a preset | ## EF Core persistence [Section titled “EF Core persistence”](#ef-core-persistence) `Granit.DataExchange.EntityFrameworkCore` provides: * **`DataExchangeDbContext`** with entities for import jobs, export jobs, saved mappings, external ID mappings, and export presets. * **`EfImportExecutor`** — batched INSERT/UPDATE executor with `SaveChanges` per batch. * **Identity resolvers**: `BusinessKeyResolver`, `CompositeKeyResolver` — query the database to determine whether each row is an INSERT or UPDATE. | Entity | Purpose | | ------------------------- | ----------------------------------------------------- | | `ImportJobEntity` | Tracks import job lifecycle and metadata | | `ExportJobEntity` | Tracks export job lifecycle and file location | | `SavedMappingEntity` | Persists confirmed column mappings for reuse (Tier 1) | | `ExternalIdMappingEntity` | Maps external identifiers to internal entity IDs | | `ExportPresetEntity` | Named field selection presets | ## Wolverine integration [Section titled “Wolverine integration”](#wolverine-integration) Without Wolverine, import and export commands dispatch via in-memory `Channel` — messages are lost on crash. Adding `Granit.DataExchange.Wolverine` replaces both dispatchers with Wolverine’s `IMessageBus` for durable outbox-backed execution: | Service | Without Wolverine | With Wolverine | | ----------------------------- | -------------------------------- | ------------------------------------- | | `IImportCommandDispatcher` | `ChannelImportCommandDispatcher` | `WolverineImportCommandDispatcher` | | `IExportCommandDispatcher` | `ChannelExportCommandDispatcher` | `WolverineExportCommandDispatcher` | | `IDataExchangeEventPublisher` | No-op | `WolverineDataExchangeEventPublisher` | ## Public API summary [Section titled “Public API summary”](#public-api-summary) | Category | Key types | Package | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | | Module | `GranitDataExchangeModule`, `GranitDataExchangeCsvModule`, `GranitDataExchangeExcelModule`, `GranitDataExchangeEntityFrameworkCoreModule`, `GranitDataExchangeEndpointsModule`, `GranitDataExchangeWolverineModule` | --- | | Import pipeline | `IImportOrchestrator`, `IMappingSuggestionService`, `IFileParser`, `IDataMapper`, `IRowValidator`, `IImportExecutor` | `Granit.DataExchange` | | Import definition | `ImportDefinition`, `ImportDefinitionBuilder`, `PropertyMappingBuilder` | `Granit.DataExchange` | | Import identity | `IRecordIdentityResolver`, `RecordIdentity`, `RecordOperation` | `Granit.DataExchange` | | Import reporting | `ImportReport`, `ImportProgress`, `ICorrectionFileGenerator` | `Granit.DataExchange` | | Export pipeline | `IExportOrchestrator`, `IExportWriter`, `IExportDataSource` | `Granit.DataExchange` | | Export definition | `ExportDefinition`, `ExportDefinitionBuilder`, `ExportFieldBuilder` | `Granit.DataExchange` | | Export presets | `IExportPresetReader`, `IExportPresetWriter` | `Granit.DataExchange` | | Mapping | `MappingConfidence`, `ImportColumnMapping`, `ISemanticMappingService` | `Granit.DataExchange` | | Options | `ImportOptions`, `ExportOptions`, `ImportExecutionOptions` | `Granit.DataExchange` | | Permissions | `DataExchangePermissions.Imports.Execute`, `DataExchangePermissions.Exports.Execute` | `Granit.DataExchange.Endpoints` | | Extensions | `AddImportDefinition()`, `AddExportDefinition()`, `AddSemanticMappingService()`, `MapDataExchangeEndpoints()` | --- | ## See also [Section titled “See also”](#see-also) * [Core module](./core/) — Module system, domain types * [Persistence module](./persistence/) — EF Core interceptors, `ApplyGranitConventions` * [Wolverine module](./wolverine/) — Transactional outbox, context propagation * [Background jobs module](./background-jobs/) — Scheduling integration * [API Reference](/api/Granit.DataExchange.html) (auto-generated from XML docs) # Granit.Identity > Identity provider abstractions, Keycloak Admin API, Cognito User Pool API, Google Cloud Identity Platform (Firebase Auth), user cache with GDPR support, REST endpoints Granit.Identity provides a provider-agnostic identity management layer. Keycloak, Cognito, Google Cloud Identity Platform (Firebase Auth), or any OIDC provider handles authentication — Granit.Identity handles everything else: user lookup, role management, session control, password operations, and a local user cache with GDPR erasure and pseudonymization built in. ## Package structure [Section titled “Package structure”](#package-structure) | Package | Role | Depends on | | ------------------------------------- | ---------------------------------------------------- | ----------------------------------------- | | `Granit.Identity` | Abstractions, null defaults | — | | `Granit.Identity.Keycloak` | Keycloak Admin API implementation | `Granit.Identity` | | `Granit.Identity.Cognito` | AWS Cognito User Pool API implementation | `Granit.Identity` | | `Granit.Identity.GoogleCloud` | Firebase Auth implementation (Firebase Admin SDK) | `Granit.Identity` | | `Granit.Authentication.GoogleCloud` | JWT Bearer for Firebase Auth + claims transformation | `Granit.Authentication.JwtBearer` | | `Granit.Identity.EntityFrameworkCore` | EF Core user cache | `Granit.Identity`, `Granit.Persistence` | | `Granit.Identity.Endpoints` | REST endpoints for user cache | `Granit.Identity`, `Granit.Authorization` | ## Dependency graph [Section titled “Dependency graph”](#dependency-graph) ``` graph TD I[Granit.Identity] --> C[Granit.Core] IK[Granit.Identity.Keycloak] --> I IC[Granit.Identity.Cognito] --> I IGC[Granit.Identity.GoogleCloud] --> I AGC[Granit.Authentication.GoogleCloud] --> JB[Granit.Authentication.JwtBearer] IEF[Granit.Identity.EntityFrameworkCore] --> I IEF --> P[Granit.Persistence] IE[Granit.Identity.Endpoints] --> I IE --> AZ[Granit.Authorization] ``` ## Setup [Section titled “Setup”](#setup) * Keycloak + User cache ```csharp [DependsOn(typeof(GranitIdentityKeycloakModule))] [DependsOn(typeof(GranitIdentityEntityFrameworkCoreModule))] [DependsOn(typeof(GranitIdentityEndpointsModule))] public class AppModule : GranitModule { public override void ConfigureServices(ServiceConfigurationContext context) { context.Services.AddGranitIdentityEntityFrameworkCore(); context.Services.AddGranitIdentityEndpoints(); } public override void OnApplicationInitialization(ApplicationInitializationContext context) { context.App.MapIdentityUserCacheEndpoints(); } } ``` * Cognito + User cache ```csharp [DependsOn(typeof(GranitIdentityCognitoModule))] [DependsOn(typeof(GranitIdentityEntityFrameworkCoreModule))] [DependsOn(typeof(GranitIdentityEndpointsModule))] public class AppModule : GranitModule { public override void ConfigureServices(ServiceConfigurationContext context) { context.Services.AddGranitIdentityEntityFrameworkCore(); context.Services.AddGranitIdentityEndpoints(); } public override void OnApplicationInitialization(ApplicationInitializationContext context) { context.App.MapIdentityUserCacheEndpoints(); } } ``` * Google Cloud (Firebase Auth) ```csharp [DependsOn(typeof(GranitIdentityGoogleCloudModule))] [DependsOn(typeof(GranitAuthenticationGoogleCloudModule))] [DependsOn(typeof(GranitIdentityEntityFrameworkCoreModule))] [DependsOn(typeof(GranitIdentityEndpointsModule))] public class AppModule : GranitModule { public override void ConfigureServices(ServiceConfigurationContext context) { context.Services.AddGranitIdentityEntityFrameworkCore(); context.Services.AddGranitIdentityEndpoints(); } public override void OnApplicationInitialization(ApplicationInitializationContext context) { context.App.MapIdentityUserCacheEndpoints(); } } ``` * Abstractions only ```csharp [DependsOn(typeof(GranitIdentityModule))] public class AppModule : GranitModule { } ``` Registers `NullIdentityProvider` and `NullUserLookupService` — useful for development or modules that only need the interfaces. * Custom provider ```csharp context.Services.AddIdentityProvider(); ``` ## Interface Segregation [Section titled “Interface Segregation”](#interface-segregation) `IIdentityProvider` is a composite interface built from 7 fine-grained interfaces following ISP (Interface Segregation Principle). Inject only what you need: | Interface | Methods | Purpose | | ----------------------------- | ---------------------------------------------------------------------------------------------------------- | ---------------------- | | `IIdentityUserReader` | `GetUsersAsync`, `GetUserAsync` | Read user profiles | | `IIdentityUserWriter` | `CreateUserAsync`, `UpdateUserAsync`, `SetUserEnabledAsync` | Mutate user profiles | | `IIdentityRoleManager` | `GetRolesAsync`, `GetUserRolesAsync`, `AssignRoleAsync`, `RemoveRoleAsync`, `GetRoleMembersAsync` | Role assignments | | `IIdentityGroupManager` | `GetGroupsAsync`, `GetUserGroupsAsync`, `AddUserToGroupAsync`, `RemoveUserFromGroupAsync` | Group membership | | `IIdentitySessionManager` | `GetUserSessionsAsync`, `GetUserDeviceActivityAsync`, `TerminateSessionAsync`, `TerminateAllSessionsAsync` | Active session control | | `IIdentityPasswordManager` | `GetPasswordChangedAtAsync`, `SendPasswordResetEmailAsync`, `SetTemporaryPasswordAsync` | Password operations | | `IIdentityCredentialVerifier` | `VerifyUserCredentialsAsync` | Credential validation | ```csharp // Inject only what you need — not the full IIdentityProvider public class UserProfileService(IIdentityUserReader userReader) { public async Task GetProfileAsync( string userId, CancellationToken cancellationToken) { return await userReader.GetUserAsync(userId, cancellationToken) .ConfigureAwait(false); } } ``` ### Provider capabilities [Section titled “Provider capabilities”](#provider-capabilities) Not all providers support every operation. Query capabilities at runtime: ```csharp public class SessionService( IIdentitySessionManager sessions, IIdentityProviderCapabilities capabilities) { public async Task TerminateSessionAsync( string userId, string sessionId, CancellationToken cancellationToken) { if (!capabilities.SupportsIndividualSessionTermination) { throw new BusinessException( "Identity:UnsupportedOperation", "Provider does not support individual session termination"); } await sessions.TerminateSessionAsync(userId, sessionId, cancellationToken) .ConfigureAwait(false); } } ``` ## User cache (cache-aside) [Section titled “User cache (cache-aside)”](#user-cache-cache-aside) `Granit.Identity.EntityFrameworkCore` maintains a local cache of user data from the identity provider. This avoids hitting Keycloak on every “who is this user?” query. ### IUserLookupService [Section titled “IUserLookupService”](#iuserlookupservice) ```csharp public interface IUserLookupService { Task FindByIdAsync(string userId, CancellationToken cancellationToken); Task> FindByIdsAsync( IReadOnlyCollection userIds, CancellationToken cancellationToken); Task> SearchAsync( string searchTerm, int page, int pageSize, CancellationToken cancellationToken); Task RefreshByIdAsync(string userId, CancellationToken cancellationToken); Task RefreshAllAsync(CancellationToken cancellationToken); Task RefreshStaleAsync(CancellationToken cancellationToken); Task DeleteByIdAsync(string userId, CancellationToken cancellationToken); Task PseudonymizeByIdAsync(string userId, CancellationToken cancellationToken); } ``` ### Cache-aside strategy [Section titled “Cache-aside strategy”](#cache-aside-strategy) ``` sequenceDiagram participant App participant Cache as UserLookupService participant DB as EF Core participant IDP as Keycloak App->>Cache: FindByIdAsync("user-123") Cache->>DB: SELECT WHERE ExternalUserId = "user-123" alt Cache hit (fresh) DB-->>Cache: UserCacheEntry Cache-->>App: UserCacheEntry else Cache miss or stale Cache->>IDP: GetUserAsync("user-123") IDP-->>Cache: IdentityUser Cache->>DB: INSERT/UPDATE UserCacheEntry Cache-->>App: UserCacheEntry end ``` ### Entity [Section titled “Entity”](#entity) ```csharp public class UserCacheEntry : AuditedEntity, IMultiTenant { public string ExternalUserId { get; set; } = string.Empty; public string? Username { get; set; } public string? Email { get; set; } public string? FirstName { get; set; } public string? LastName { get; set; } public bool Enabled { get; set; } public DateTimeOffset? LastSyncedAt { get; set; } public Guid? TenantId { get; set; } } ``` ### Login-time sync [Section titled “Login-time sync”](#login-time-sync) `UserCacheSyncMiddleware` automatically refreshes the cache entry when a user authenticates. This ensures the cache stays fresh without polling. ### Configuration [Section titled “Configuration”](#configuration) ```json { "IdentityUserCache": { "StalenessThreshold": "1.00:00:00", "EnableLoginTimeSync": true, "IncrementalSyncBatchSize": 50 } } ``` | Property | Default | Description | | -------------------------- | ---------- | ------------------------------------------------- | | `StalenessThreshold` | `24:00:00` | Age after which a cache entry is considered stale | | `EnableLoginTimeSync` | `true` | Refresh cache on login | | `IncrementalSyncBatchSize` | `50` | Batch size for `RefreshStaleAsync` | ## Keycloak provider [Section titled “Keycloak provider”](#keycloak-provider) `Granit.Identity.Keycloak` implements the full `IIdentityProvider` against the Keycloak Admin REST API. ### Configuration [Section titled “Configuration”](#configuration-1) ```json { "KeycloakAdmin": { "BaseUrl": "https://keycloak.example.com", "Realm": "my-realm", "ClientId": "admin-cli", "ClientSecret": "secret", "TimeoutSeconds": 30, "UseTokenExchangeForDeviceActivity": false, "DirectAccessClientId": "direct-access-client" } } ``` **Required Keycloak service account roles:** * `realm-management:view-users` * `realm-management:manage-users` ### Health check [Section titled “Health check”](#health-check) ```csharp builder.Services.AddHealthChecks() .AddGranitKeycloakHealthCheck(); ``` Verifies Keycloak connectivity by requesting a `client_credentials` token. Tagged `["readiness", "startup"]`. Returns Unhealthy on 401/403, Degraded on 5xx. ### OpenTelemetry [Section titled “OpenTelemetry”](#opentelemetry) All Keycloak Admin API calls are traced via `IdentityKeycloakActivitySource`. Activity names follow the pattern `Granit.Identity.Keycloak.{Operation}`. ## Cognito provider [Section titled “Cognito provider”](#cognito-provider) `Granit.Identity.Cognito` implements the full `IIdentityProvider` against the AWS Cognito User Pool API via `AWSSDK.CognitoIdentityProvider`. ### Configuration [Section titled “Configuration”](#configuration-2) ```json { "CognitoAdmin": { "UserPoolId": "eu-west-1_XXXXXXXXX", "Region": "eu-west-1", "AccessKeyId": "", "SecretAccessKey": "", "TimeoutSeconds": 30 } } ``` `AccessKeyId` and `SecretAccessKey` are optional — when omitted, the SDK uses the default credential chain (IAM role, environment variables, `~/.aws/credentials`). ### Cognito-specific behavior [Section titled “Cognito-specific behavior”](#cognito-specific-behavior) * **Groups as roles** — Cognito has no native “roles” concept. Groups serve as both roles and groups. `GetRolesAsync` and `GetGroupsAsync` return the same data. * **No individual session termination** — Cognito supports `AdminUserGlobalSignOut` (terminate all sessions) but not individual session termination. `IIdentityProviderCapabilities.SupportsIndividualSessionTermination` returns `false`. * **No device activity** — `GetUserDeviceActivityAsync` returns an empty list. ### OpenTelemetry [Section titled “OpenTelemetry”](#opentelemetry-1) All Cognito User Pool API calls are traced via `IdentityCognitoActivitySource`. Activity names follow the pattern `cognito.{operation}`. ## Google Cloud Identity Platform provider [Section titled “Google Cloud Identity Platform provider”](#google-cloud-identity-platform-provider) `Granit.Identity.GoogleCloud` implements the full `IIdentityProvider` against the Firebase Auth API via Firebase Admin SDK v3. ### Configuration [Section titled “Configuration”](#configuration-3) ```json { "Identity": { "GoogleCloud": { "ProjectId": "my-firebase-project", "RolesClaimKey": "roles", "TimeoutSeconds": 30 } } } ``` For Workload Identity (recommended in GKE), omit `CredentialFilePath` — Application Default Credentials are used automatically. ### Google Cloud-specific behavior [Section titled “Google Cloud-specific behavior”](#google-cloud-specific-behavior) * **Roles via custom claims** — Firebase Auth has no native roles. User roles are stored as a JSON array in custom claims (configurable key, default `"roles"`). `GetRolesAsync` returns an empty list; `GetUserRolesAsync` extracts from claims. * **Groups not supported** — Firebase Auth does not support groups. `AddUserToGroupAsync` and `RemoveUserFromGroupAsync` throw `NotSupportedException`. * **No individual session termination** — Firebase supports `RevokeRefreshTokens` (terminate all sessions) but not individual session termination. `IIdentityProviderCapabilities.SupportsIndividualSessionTermination` returns `false`. * **Credential verification** — `VerifyUserCredentialsAsync` validates email/password via the Firebase Auth REST API. ### Health check [Section titled “Health check”](#health-check-1) ```csharp builder.Services.AddHealthChecks() .AddGranitGoogleCloudIdentityHealthCheck(); ``` Verifies that `ProjectId` is configured. Tagged `["readiness"]`. ### OpenTelemetry [Section titled “OpenTelemetry”](#opentelemetry-2) All Firebase Auth API calls are traced via `IdentityGoogleCloudActivitySource`. Activity names follow the pattern `firebase.{operation}`. ## Google Cloud Authentication [Section titled “Google Cloud Authentication”](#google-cloud-authentication) `Granit.Authentication.GoogleCloud` configures JWT Bearer authentication for Firebase Auth tokens and transforms custom claims into standard `ClaimTypes.Role`. ### Configuration [Section titled “Configuration”](#configuration-4) ```json { "GoogleCloudAuth": { "ProjectId": "my-firebase-project", "RequireHttpsMetadata": true, "AdminRole": "admin", "RolesClaimKey": "roles" } } ``` | Property | Default | Description | | ---------------------- | --------- | ---------------------------------------------- | | `ProjectId` | — | GCP project ID (derives OIDC authority) | | `RequireHttpsMetadata` | `true` | Require HTTPS for OIDC metadata discovery | | `AdminRole` | `"admin"` | Role name for the “Admin” authorization policy | | `RolesClaimKey` | `"roles"` | Custom claims key containing roles | The module derives the OIDC authority as `https://securetoken.google.com/{ProjectId}` and configures JWT Bearer validation with `Audience = ProjectId`, `NameClaimType = "email"`. ### Claims transformation [Section titled “Claims transformation”](#claims-transformation) `GoogleCloudClaimsTransformation` maps Firebase custom claims to `ClaimTypes.Role`. Supports JSON array format (`["admin","editor"]`) and single string values. Existing `ClaimTypes.Role` claims are preserved without duplication. ## Endpoints [Section titled “Endpoints”](#endpoints) `Granit.Identity.Endpoints` exposes user cache management as Minimal API endpoints. | Method | Route | Permission | Description | | ------ | ----------------------------------- | ---------------------------------- | ----------------------- | | GET | `/identity/users/search` | `Identity.UserCache.Read` | Search cached users | | GET | `/identity/users/{id}` | `Identity.UserCache.Read` | Get by external ID | | POST | `/identity/users/batch` | `Identity.UserCache.Read` | Batch resolve by IDs | | POST | `/identity/users/sync` | `Identity.UserCache.Sync` | Sync specific user | | POST | `/identity/users/sync-all` | `Identity.UserCache.Sync` | Full sync from provider | | DELETE | `/identity/users/{id}` | `Identity.UserCache.Delete` | GDPR erase | | PATCH | `/identity/users/{id}/pseudonymize` | `Identity.UserCache.Delete` | GDPR pseudonymize | | GET | `/identity/users/stats` | `Identity.UserCache.Read` | Cache statistics | | GET | `/identity/users/capabilities` | `Identity.UserCache.Read` | Provider capabilities | | POST | `/identity-webhook` | *(anonymous, signature-validated)* | IdP webhook receiver | ### Webhook [Section titled “Webhook”](#webhook) The webhook endpoint receives events from the identity provider (user created, updated, deleted) and updates the local cache accordingly. Payload authenticity is verified via HMAC signature validation. ## GDPR operations [Section titled “GDPR operations”](#gdpr-operations) Two operations support data subject rights: ```csharp // Right to erasure (Art. 17) — hard delete await userLookupService.DeleteByIdAsync("user-123", cancellationToken) .ConfigureAwait(false); // Right to restriction (Art. 18) — pseudonymize await userLookupService.PseudonymizeByIdAsync("user-123", cancellationToken) .ConfigureAwait(false); ``` * **Delete** removes the `UserCacheEntry` entirely (physical delete, not soft delete) * **Pseudonymize** replaces PII fields with anonymized values while preserving the record Both operations emit Wolverine domain events (`IdentityUserDeletedEvent`, `IdentityUserUpdatedEvent`) for downstream modules to react. ## Public API summary [Section titled “Public API summary”](#public-api-summary) | Category | Key types | Package | | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- | | Module | `GranitIdentityModule`, `GranitIdentityKeycloakModule`, `GranitIdentityCognitoModule`, `GranitIdentityGoogleCloudModule`, `GranitAuthenticationGoogleCloudModule`, `GranitIdentityEntityFrameworkCoreModule`, `GranitIdentityEndpointsModule` | — | | Abstractions | `IIdentityProvider`, `IIdentityUserReader`, `IIdentityUserWriter`, `IIdentityRoleManager`, `IIdentityGroupManager`, `IIdentitySessionManager`, `IIdentityPasswordManager`, `IIdentityCredentialVerifier` | `Granit.Identity` | | Lookup | `IUserLookupService`, `IUserCacheStats`, `IIdentityProviderCapabilities` | `Granit.Identity` | | Models | `IdentityUser`, `IdentityUserCreate`, `IdentityUserUpdate`, `IdentityRole`, `IdentityGroup`, `IdentitySession`, `IdentityDeviceActivity` | `Granit.Identity` | | Keycloak | `KeycloakIdentityProvider`, `KeycloakAdminOptions` | `Granit.Identity.Keycloak` | | Cognito | `CognitoIdentityProvider`, `CognitoAdminOptions` | `Granit.Identity.Cognito` | | Google Cloud | `GoogleCloudIdentityProvider`, `GoogleCloudIdentityOptions` | `Granit.Identity.GoogleCloud` | | Google Cloud Auth | `GoogleCloudClaimsTransformation`, `GoogleCloudAuthenticationOptions` | `Granit.Authentication.GoogleCloud` | | EF Core | `UserCacheEntry`, `IUserCacheDbContext`, `UserCacheOptions` | `Granit.Identity.EntityFrameworkCore` | | Endpoints | `IdentityEndpointsOptions`, `IdentityWebhookOptions` | `Granit.Identity.Endpoints` | | Extensions | `AddGranitIdentity()`, `AddIdentityProvider()`, `AddGranitIdentityKeycloak()`, `AddGranitIdentityCognito()`, `AddGranitIdentityGoogleCloud()`, `AddGranitGoogleCloudAuthentication()`, `AddGranitIdentityEntityFrameworkCore()`, `AddGranitIdentityEndpoints()`, `MapIdentityUserCacheEndpoints()` | — | ## See also [Section titled “See also”](#see-also) * [Security module](./security/) — Authentication, authorization, RBAC permissions * [Privacy module](./privacy/) — GDPR data export/deletion, cookie consent * [Persistence module](./persistence/) — `AuditedEntity`, interceptors * [API Reference](/api/Granit.Identity.html) (auto-generated from XML docs) # Granit.Localization > JSON-based i18n (17 cultures), override store, source-generated keys, Minimal API endpoints Granit.Localization provides a modular, JSON-based internationalization system for .NET applications. Resources are embedded as JSON files, auto-discovered at startup, and served via `IStringLocalizer`. Translation overrides live in a database (EF Core) and are cached in-memory with per-tenant isolation. A Roslyn source generator produces strongly-typed key constants at build time, eliminating magic strings. ## Package structure [Section titled “Package structure”](#package-structure) | Package | Role | Depends on | | ----------------------------------------- | ------------------------------------------------------------------------- | --------------------------------------------- | | `Granit.Localization` | `GranitLocalizationModule`, JSON localizer, override store abstractions | `Granit.Core` | | `Granit.Localization.EntityFrameworkCore` | `GranitLocalizationOverridesDbContext`, `EfCoreLocalizationOverrideStore` | `Granit.Localization`, `Granit.Persistence` | | `Granit.Localization.Endpoints` | Anonymous SPA endpoint, override CRUD endpoints | `Granit.Localization`, `Granit.Authorization` | | `Granit.Localization.SourceGenerator` | Build-time `LocalizationKeys` class generation | *(analyzer, no runtime dependency)* | ## Dependency graph [Section titled “Dependency graph”](#dependency-graph) ``` graph TD L[Granit.Localization] --> CO[Granit.Core] EF[Granit.Localization.EntityFrameworkCore] --> L EF --> P[Granit.Persistence] EP[Granit.Localization.Endpoints] --> L EP --> A[Granit.Authorization] SG[Granit.Localization.SourceGenerator] -.->|build-time| L ``` ## Setup [Section titled “Setup”](#setup) * Core only ```csharp [DependsOn(typeof(GranitLocalizationModule))] public class AppModule : GranitModule { public override void ConfigureServices(ServiceConfigurationContext context) { context.Services.Configure(options => { // Register your application resource options.Resources .Add("fr") .AddJson( typeof(AcmeLocalizationResource).Assembly, "Acme.App.Localization.Acme"); // Add supported languages options.Languages.Add(new LanguageInfo("nl", "Nederlands", "nl")); options.Languages.Add(new LanguageInfo("de", "Deutsch", "de")); }); } } ``` Default languages registered by the module: **fr**, **fr-CA**, **en** (default), **en-GB**. * With EF Core overrides ```csharp [DependsOn( typeof(GranitLocalizationModule), typeof(GranitLocalizationEntityFrameworkCoreModule))] public class AppModule : GranitModule { } ``` Program.cs ```csharp builder.AddGranitLocalizationEntityFrameworkCore(opt => opt.UseNpgsql(connectionString)); ``` * With endpoints ```csharp [DependsOn( typeof(GranitLocalizationModule), typeof(GranitLocalizationEntityFrameworkCoreModule), typeof(GranitLocalizationEndpointsModule))] public class AppModule : GranitModule { } ``` ```csharp // Program.cs — map endpoints app.MapGranitLocalization(); // GET /localization (anonymous) app.MapGranitLocalizationOverrides(); // CRUD /localization/overrides (admin) ``` ## Resource registration [Section titled “Resource registration”](#resource-registration) Each module registers a **marker type** and its embedded JSON files via `GranitLocalizationOptions.Resources`: ```csharp options.Resources .Add("fr") // default culture = "fr" .AddJson( typeof(AcmeLocalizationResource).Assembly, "Acme.App.Localization.Acme") // embedded resource prefix .AddBaseTypes(typeof(GranitLocalizationResource)); // inherit Granit keys ``` ### LocalizationResourceStore [Section titled “LocalizationResourceStore”](#localizationresourcestore) The `LocalizationResourceStore` holds all registered resources. Each entry is a `LocalizationResourceInfo` with: | Property | Description | | ---------------- | ------------------------------------ | | `ResourceType` | Marker type (empty class) | | `DefaultCulture` | Fallback culture (e.g. `"fr"`) | | `BaseTypes` | Parent resources for key inheritance | | `JsonSources` | Embedded JSON file locations | ### Auto-discovery [Section titled “Auto-discovery”](#auto-discovery) When `EnableAutoDiscovery = true` (default), loaded module assemblies are scanned for types decorated with `[LocalizationResourceName]`. Their embedded JSON files are registered automatically without explicit `AddJson()` calls. ## JSON file format [Section titled “JSON file format”](#json-file-format) Files follow the Granit convention: `{ "culture": "...", "texts": { ... } }`. Nested keys are flattened with `.` separators. Localization/Acme/en.json ```json { "culture": "en", "texts": { "Patient": { "Created": "Patient {0} created successfully.", "NotFound": "Patient not found." }, "Validation": { "NissRequired": "National identification number is required." } } } ``` Usage in code: ```csharp public class PatientHandler(IStringLocalizer l) { public string GetMessage(string name) => l["Patient.Created", name]; } ``` ## LanguageInfo [Section titled “LanguageInfo”](#languageinfo) Available languages are declared via `GranitLocalizationOptions.Languages`: ```csharp new LanguageInfo( cultureName: "fr-CA", displayName: "Francais (Canada)", flagIcon: "ca", isDefault: false) ``` | Property | Description | | ------------- | -------------------------------------------------- | | `CultureName` | BCP 47 culture code (`"fr"`, `"en-GB"`) | | `DisplayName` | UI-friendly name | | `FlagIcon` | Optional icon identifier for the language selector | | `IsDefault` | Pre-selected language in the UI | The `Languages` list also drives `SupportedUICultures` for ASP.NET Core request localization (`UseGranitRequestLocalization`). ## Translation overrides [Section titled “Translation overrides”](#translation-overrides) Overrides allow administrators to customize translations at runtime without redeploying. They are stored per resource, per culture, per tenant. ### Abstractions [Section titled “Abstractions”](#abstractions) ```csharp public interface ILocalizationOverrideStoreReader { Task> GetOverridesAsync( string resourceName, string culture, CancellationToken cancellationToken = default); } public interface ILocalizationOverrideStoreWriter { Task SetOverrideAsync( string resourceName, string culture, string key, string value, CancellationToken cancellationToken = default); Task RemoveOverrideAsync( string resourceName, string culture, string key, CancellationToken cancellationToken = default); } ``` ### Caching layer [Section titled “Caching layer”](#caching-layer) The `CachedLocalizationOverrideStore` wraps the EF Core store with an `IMemoryCache` layer. Cache keys are tenant-scoped (`localization:{tenantId}:{resource}:{culture}`) and invalidated on every write. ``` sequenceDiagram participant L as IStringLocalizer participant C as CachedLocalizationOverrideStore participant M as IMemoryCache participant EF as EfCoreLocalizationOverrideStore L->>C: GetOverridesAsync("Acme", "fr") C->>M: TryGetValue(cacheKey) alt Cache hit M-->>C: overrides else Cache miss C->>EF: GetOverridesAsync("Acme", "fr") EF-->>C: overrides C->>M: Set(cacheKey, overrides, TTL) end C-->>L: overrides ``` ## Endpoints [Section titled “Endpoints”](#endpoints) ### SPA bootstrapping [Section titled “SPA bootstrapping”](#spa-bootstrapping) `GET /localization?cultureName=fr` returns all registered resources for the requested culture, plus the list of available languages. This endpoint is **anonymous** and includes cache headers (`Cache-Control: public, max-age=3600`, `Vary: Accept-Language`). ```json { "currentCulture": "fr", "resources": { "Acme": { "Patient.Created": "Patient {0} cree avec succes.", "Patient.NotFound": "Patient introuvable." }, "Granit": { "EntityNotFound": "Entite introuvable." } }, "languages": [ { "cultureName": "fr", "displayName": "Francais (France)", "flagIcon": "fr", "isDefault": false }, { "cultureName": "en", "displayName": "English (United States)", "flagIcon": "us", "isDefault": true } ] } ``` ### Override management [Section titled “Override management”](#override-management) All override endpoints require the `Localization.Overrides.Manage` permission. | Method | Route | Description | | -------- | ------------------------------------------------------------ | ------------------------------------- | | `GET` | `/localization/overrides?resourceName=X&cultureName=fr` | List overrides for a resource/culture | | `PUT` | `/localization/overrides/{resourceName}/{cultureName}/{key}` | Create or update an override | | `DELETE` | `/localization/overrides/{resourceName}/{cultureName}/{key}` | Remove an override | If no `ILocalizationOverrideStoreReader`/`Writer` is registered (EF Core package not installed), all override endpoints return `501 Not Implemented`. ## Source generator [Section titled “Source generator”](#source-generator) The `Granit.Localization.SourceGenerator` package provides a Roslyn incremental generator that reads JSON localization files declared as `` and produces a `LocalizationKeys` class with nested constant string fields. ### Setup [Section titled “Setup”](#setup-1) Acme.App.csproj ```xml ``` ### Generated output [Section titled “Generated output”](#generated-output) Given the JSON file from the example above, the generator produces: LocalizationKeys.g.cs (auto-generated) ```csharp public static class LocalizationKeys { public static class Patient { public const string Created = "Patient.Created"; public const string NotFound = "Patient.NotFound"; } public static class Validation { public const string NissRequired = "Validation.NissRequired"; } } ``` Usage with `IStringLocalizer`: ```csharp string message = localizer[LocalizationKeys.Patient.Created, patientName]; ``` Keys with the `Resource:Key` convention (e.g., `"Granit:EntityNotFound"`) produce nested classes matching the resource prefix. ## Public API summary [Section titled “Public API summary”](#public-api-summary) | Category | Key types | Package | | ------------ | -------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | | Module | `GranitLocalizationModule`, `GranitLocalizationEntityFrameworkCoreModule`, `GranitLocalizationEndpointsModule` | — | | Core | `LocalizationResourceStore`, `LocalizationResourceInfo`, `LanguageInfo` | `Granit.Localization` | | Options | `GranitLocalizationOptions` (`EnableAutoDiscovery`, `Resources`, `Languages`, `FormattingCultures`) | `Granit.Localization` | | Abstractions | `ILocalizationOverrideStoreReader`, `ILocalizationOverrideStoreWriter` | `Granit.Localization` | | Caching | `CachedLocalizationOverrideStore` *(internal)* | `Granit.Localization` | | EF Core | `GranitLocalizationOverridesDbContext`, `EfCoreLocalizationOverrideStore` *(internal)* | `Granit.Localization.EntityFrameworkCore` | | Endpoints | `MapGranitLocalization()`, `MapGranitLocalizationOverrides()` | `Granit.Localization.Endpoints` | | Generator | `LocalizationKeysGenerator` | `Granit.Localization.SourceGenerator` | ## See also [Section titled “See also”](#see-also) * [Settings, Features & Reference Data](./settings-features/) — uses localization for display names * [Persistence module](./persistence/) — EF Core interceptors used by the override store * [Security module](./security/) — permission-based access for override management # Granit.MultiTenancy > Tenant resolution pipeline, AsyncLocal context, isolation strategies (shared DB, schema, database per tenant) Granit.MultiTenancy adds HTTP-based tenant resolution to the framework. A pipeline of pluggable resolvers extracts the tenant from headers or JWT claims, sets an `AsyncLocal` context for the entire request, and EF Core query filters handle the rest. Three isolation strategies available — from shared database to database-per-tenant. ## Package structure [Section titled “Package structure”](#package-structure) Single package — isolation strategies are configured in `Granit.Persistence`. | Package | Role | Depends on | | --------------------- | ---------------------------------------------- | ------------- | | `Granit.MultiTenancy` | Tenant resolution, `CurrentTenant`, middleware | `Granit.Core` | ## Setup [Section titled “Setup”](#setup) ```csharp [DependsOn(typeof(GranitMultiTenancyModule))] public class AppModule : GranitModule { } ``` ```json { "MultiTenancy": { "IsEnabled": true, "TenantIdClaimType": "tenant_id", "TenantIdHeaderName": "X-Tenant-Id" } } ``` **Pipeline order is critical:** ```csharp app.UseAuthentication(); app.UseGranitMultiTenancy(); // After auth, before authorization app.UseAuthorization(); ``` ## Tenant resolution pipeline [Section titled “Tenant resolution pipeline”](#tenant-resolution-pipeline) Resolvers execute in `Order` (ascending). First non-null result wins. ``` flowchart LR R[Request] --> H{Header?} H -->|X-Tenant-Id| T[TenantInfo] H -->|missing| J{JWT claim?} J -->|tenant_id| T J -->|missing| N[No tenant] T --> M[Middleware sets AsyncLocal] M --> A[Request continues] ``` | Resolver | Order | Source | | ------------------------ | ----- | ------------------------- | | `HeaderTenantResolver` | 100 | `X-Tenant-Id` HTTP header | | `JwtClaimTenantResolver` | 200 | `tenant_id` JWT claim | ### Custom resolver [Section titled “Custom resolver”](#custom-resolver) ```csharp public sealed class SubdomainTenantResolver( ITenantStore tenantStore) : ITenantResolver { public int Order => 50; // Before header resolver public async Task ResolveAsync( HttpContext context, CancellationToken cancellationToken = default) { string host = context.Request.Host.Host; string subdomain = host.Split('.')[0]; var tenant = await tenantStore .FindBySubdomainAsync(subdomain, cancellationToken) .ConfigureAwait(false); return tenant is not null ? new TenantInfo(tenant.Id, tenant.Name) : null; } } ``` Register it in DI — the pipeline auto-discovers all `ITenantResolver` implementations. ## CurrentTenant [Section titled “CurrentTenant”](#currenttenant) The real implementation uses `AsyncLocal` for thread-safe, per-async-flow isolation: ```csharp // In any service — constructor injection public class PatientService(ICurrentTenant currentTenant, AppDbContext db) { public async Task> GetAllAsync(CancellationToken cancellationToken) { // EF Core query filter automatically applies WHERE TenantId = @tenantId return await db.Patients .ToListAsync(cancellationToken) .ConfigureAwait(false); } } ``` ### Temporary tenant override [Section titled “Temporary tenant override”](#temporary-tenant-override) Switch tenant context for background jobs or cross-tenant admin operations: ```csharp public class TenantMigrationService(ICurrentTenant currentTenant) { public async Task MigrateDataAsync( Guid sourceTenantId, Guid targetTenantId, CancellationToken cancellationToken) { using (currentTenant.Change(sourceTenantId)) { var data = await LoadDataAsync(cancellationToken).ConfigureAwait(false); using (currentTenant.Change(targetTenantId)) { await SaveDataAsync(data, cancellationToken).ConfigureAwait(false); } // Back to source tenant } // Back to original tenant (or no tenant) } } ``` ### AllowAnonymousTenant [Section titled “AllowAnonymousTenant”](#allowanonymoustenant) Mark endpoints that don’t require a tenant context: ```csharp [AllowAnonymousTenant] app.MapGet("/health", () => Results.Ok()); ``` `Granit.ApiDocumentation` automatically excludes the `X-Tenant-Id` header from OpenAPI docs for these endpoints. ## Isolation strategies [Section titled “Isolation strategies”](#isolation-strategies) Three strategies configured in `Granit.Persistence`: | Strategy | Isolation | Query filter | Connection | | ------------------- | ---------------------------- | -------------------------------------- | ------------------------------------- | | `SharedDatabase` | `WHERE TenantId = @id` | Automatic via `ApplyGranitConventions` | Single connection string | | `SchemaPerTenant` | PostgreSQL `SET search_path` | Per-schema tables | Single connection, per-request schema | | `DatabasePerTenant` | Separate database | N/A (physical isolation) | Per-tenant connection from Vault | * Shared database Default strategy. All tenants share one database, isolated by EF Core query filters on `IMultiTenant` entities. ```json { "TenantIsolation": { "Strategy": "SharedDatabase" } } ``` No additional setup — `ApplyGranitConventions` handles the `TenantId` filter. * Schema per tenant Each tenant gets a dedicated PostgreSQL schema. `TenantSchemaConnectionInterceptor` executes `SET search_path` on every connection open. ```csharp context.Services.AddTenantPerSchemaDbContext( options => options.UseNpgsql( context.Configuration.GetConnectionString("Default"))); ``` ```json { "TenantIsolation": { "Strategy": "SchemaPerTenant" }, "TenantSchema": { "NamingConvention": "TenantId", "Prefix": "tenant_" } } ``` * Database per tenant Maximum isolation — each tenant has its own database. Connection strings resolved from Vault via `ITenantConnectionStringProvider`. ```csharp context.Services.AddSingleton(); context.Services.AddTenantPerDatabaseDbContext( static (options, connectionString) => options.UseNpgsql(connectionString)); ``` ```json { "TenantIsolation": { "Strategy": "DatabasePerTenant" } } ``` Danger Schema activators execute on every connection open. Pooled connections retain the previous tenant’s schema — failing to reset is a **data isolation breach** (ISO 27001). Never cache or skip the `SET search_path` call. ## Wolverine context propagation [Section titled “Wolverine context propagation”](#wolverine-context-propagation) When Wolverine dispatches messages, the `OutgoingContextMiddleware` injects `X-Tenant-Id` into the message envelope. The `TenantContextBehavior` restores it on the receiving side: ```plaintext HTTP Request (Tenant A) → Wolverine handler → background job X-Tenant-Id: A ───────────────────→ ICurrentTenant.Id = A ``` This ensures tenant isolation across async message processing without manual propagation. ## Configuration reference [Section titled “Configuration reference”](#configuration-reference) | Property | Default | Description | | -------------------- | --------------- | -------------------------------- | | `IsEnabled` | `true` | Enable/disable tenant resolution | | `TenantIdClaimType` | `"tenant_id"` | JWT claim name for tenant ID | | `TenantIdHeaderName` | `"X-Tenant-Id"` | HTTP header name for tenant ID | ## Public API summary [Section titled “Public API summary”](#public-api-summary) | Category | Key types | Package | | --------------------- | --------------------------------------------------------------------------------------------- | --------------------- | | Module | `GranitMultiTenancyModule` | `Granit.MultiTenancy` | | Core (in Granit.Core) | `ICurrentTenant`, `AllowAnonymousTenantAttribute` | `Granit.Core` | | Implementation | `CurrentTenant`, `TenantInfo`, `ITenantInfo` | `Granit.MultiTenancy` | | Resolution | `ITenantResolver`, `HeaderTenantResolver`, `JwtClaimTenantResolver`, `TenantResolverPipeline` | `Granit.MultiTenancy` | | Middleware | `TenantResolutionMiddleware` | `Granit.MultiTenancy` | | Options | `MultiTenancyOptions` | `Granit.MultiTenancy` | | Extensions | `AddGranitMultiTenancy()`, `UseGranitMultiTenancy()` | `Granit.MultiTenancy` | ## See also [Section titled “See also”](#see-also) * [Core module](./core/) — `IMultiTenant` interface, data filter * [Persistence module](./persistence/) — `ApplyGranitConventions`, isolation strategies * [Wolverine module](./wolverine/) — Tenant context propagation in messages * [API Reference](/api/Granit.MultiTenancy.html) (auto-generated from XML docs) # Granit.Notifications > Multi-channel notification engine with fan-out pattern, entity tracking, delivery audit trail, and pluggable channels Granit.Notifications is a multi-channel notification engine built on a fan-out pattern. A single `INotificationPublisher.PublishAsync()` call fans out to every registered channel (InApp, Email, SMS, WhatsApp, Mobile Push, SignalR, SSE, Web Push, Zulip) after filtering through user preferences. Delivery attempts are recorded in an immutable audit trail (ISO 27001). By default, notifications dispatch via an in-process `Channel` — add `Granit.Notifications.Wolverine` for durable outbox-backed dispatch with exponential backoff retry. ## Package structure [Section titled “Package structure”](#package-structure) | Package | Role | Depends on | | ------------------------------------------------------- | ------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- | | `Granit.Notifications` | Fan-out engine, InApp channel, definitions, entity tracking | `Granit.Guids`, `Granit.Timing`, `Granit.Querying` | | `Granit.Notifications.EntityFrameworkCore` | EF Core stores (all entities + mobile push tokens) | `Granit.Notifications`, `Granit.Notifications.MobilePush`, `Granit.Persistence` | | `Granit.Notifications.Endpoints` | Minimal API endpoints (inbox, preferences, subscriptions, followers) | `Granit.Notifications`, `Granit.Notifications.MobilePush`, `Granit.Validation`, `Granit.ApiDocumentation` | | `Granit.Notifications.Wolverine` | Durable outbox-backed dispatch via `IMessageBus` | `Granit.Notifications`, `Granit.Wolverine` | | `Granit.Notifications.Email` | Email channel abstraction, Keyed Services resolution | `Granit.Notifications` | | `Granit.Notifications.Email.Smtp` | MailKit SMTP provider (Keyed Service `"Smtp"`) | `Granit.Notifications.Email` | | `Granit.Notifications.Email.AzureCommunicationServices` | Azure Communication Services email provider (Keyed Service `"AzureCommunicationServices"`) | `Granit.Notifications.Email` | | `Granit.Notifications.Email.Scaleway` | Scaleway TEM email provider (Keyed Service `"Scaleway"`) | `Granit.Notifications.Email` | | `Granit.Notifications.Email.SendGrid` | SendGrid email provider (Keyed Service `"SendGrid"`) | `Granit.Notifications.Email` | | `Granit.Notifications.Brevo` | Unified Brevo provider (Email + SMS + WhatsApp) | `Granit.Notifications.Email`, `Granit.Notifications.Sms`, `Granit.Notifications.WhatsApp` | | `Granit.Notifications.Sms` | SMS channel abstraction, Keyed Services resolution | `Granit.Notifications` | | `Granit.Notifications.Sms.AzureCommunicationServices` | Azure Communication Services SMS provider (Keyed Service `"AzureCommunicationServices"`) | `Granit.Notifications.Sms` | | `Granit.Notifications.Sms.AwsSns` | AWS SNS SMS provider (Keyed Service `"AwsSns"`) | `Granit.Notifications.Sms` | | `Granit.Notifications.WhatsApp` | WhatsApp Business API channel | `Granit.Notifications` | | `Granit.Notifications.MobilePush` | Mobile push abstraction + device token store | `Granit.Notifications` | | `Granit.Notifications.MobilePush.GoogleFcm` | Firebase Cloud Messaging (FCM HTTP v1 API) | `Granit.Notifications.MobilePush` | | `Granit.Notifications.MobilePush.AzureNotificationHubs` | Azure Notification Hubs push provider (Keyed Service `"AzureNotificationHubs"`) | `Granit.Notifications.MobilePush` | | `Granit.Notifications.MobilePush.AwsSns` | AWS SNS Platform Application push provider (Keyed Service `"AwsSns"`) | `Granit.Notifications.MobilePush` | | `Granit.Notifications.SignalR` | Real-time SignalR channel + Redis backplane | `Granit.Notifications` | | `Granit.Notifications.WebPush` | W3C Web Push (VAPID, RFC 8030/8291/8292) | `Granit.Notifications` | | `Granit.Notifications.Sse` | Server-Sent Events channel (.NET 10 native SSE) | `Granit.Notifications` | | `Granit.Notifications.Twilio` | Twilio SMS + WhatsApp provider (Keyed Service `"Twilio"`) | `Granit.Notifications.Sms`, `Granit.Notifications.WhatsApp` | | `Granit.Notifications.Zulip` | Zulip Bot API chat integration | `Granit.Notifications` | ## Dependency graph [Section titled “Dependency graph”](#dependency-graph) ``` graph TD N[Granit.Notifications] --> G[Granit.Guids] N --> T[Granit.Timing] N --> Q[Granit.Querying] NEF[Granit.Notifications.EntityFrameworkCore] --> N NEF --> MP[Granit.Notifications.MobilePush] NEF --> P[Granit.Persistence] NE[Granit.Notifications.Endpoints] --> N NE --> MP NE --> V[Granit.Validation] NE --> AD[Granit.ApiDocumentation] NW[Granit.Notifications.Wolverine] --> N NW --> W[Granit.Wolverine] EM[Granit.Notifications.Email] --> N SMTP[Granit.Notifications.Email.Smtp] --> EM ACSEM[Granit.Notifications.Email.AzureCommunicationServices] --> EM SCW[Granit.Notifications.Email.Scaleway] --> EM SG[Granit.Notifications.Email.SendGrid] --> EM BR[Granit.Notifications.Brevo] --> EM BR --> SMS[Granit.Notifications.Sms] BR --> WA[Granit.Notifications.WhatsApp] SMS --> N ACSSMS[Granit.Notifications.Sms.AzureCommunicationServices] --> SMS SNSSMS[Granit.Notifications.Sms.AwsSns] --> SMS TW[Granit.Notifications.Twilio] --> SMS TW --> WA WA --> N MP --> N FCM[Granit.Notifications.MobilePush.GoogleFcm] --> MP ANH[Granit.Notifications.MobilePush.AzureNotificationHubs] --> MP SNSMP[Granit.Notifications.MobilePush.AwsSns] --> MP SR[Granit.Notifications.SignalR] --> N WP[Granit.Notifications.WebPush] --> N SSE[Granit.Notifications.Sse] --> N ZU[Granit.Notifications.Zulip] --> N ``` ## Setup [Section titled “Setup”](#setup) * Production (Wolverine + EF Core) ```csharp [DependsOn(typeof(GranitNotificationsWolverineModule))] [DependsOn(typeof(GranitNotificationsEntityFrameworkCoreModule))] public class AppModule : GranitModule { public override void ConfigureServices(ServiceConfigurationContext context) { // EF Core persistence (replaces in-memory defaults) context.Builder.AddGranitNotificationsEntityFrameworkCore( opts => opts.UseNpgsql(context.Configuration .GetConnectionString("Notifications"))); // Email channel via SMTP context.Services.AddGranitNotificationsEmail(opts => opts.Provider = "Smtp"); context.Services.AddGranitNotificationsEmailSmtp(); // SignalR real-time channel with Redis backplane context.Services.AddGranitNotificationsSignalR( context.Configuration.GetConnectionString("Redis")!); // Notification endpoints context.Services.AddGranitNotificationsEndpoints(); } public override void OnApplicationInitialization(ApplicationInitializationContext context) { context.App.MapGranitNotificationEndpoints(); context.App.MapHub("/hubs/notifications"); } } ``` * Development (in-memory) ```csharp [DependsOn(typeof(GranitNotificationsModule))] public class AppModule : GranitModule { } ``` In-memory stores, in-process `Channel` dispatch. No database, no outbox. Notifications are lost on crash — suitable for development only. * With Brevo (Email + SMS + WhatsApp) ```csharp // One provider for three channels context.Services.AddGranitNotificationsBrevo(); context.Services.AddGranitNotificationsEmail(opts => opts.Provider = "Brevo"); context.Services.AddGranitNotificationsSms(opts => opts.Provider = "Brevo"); context.Services.AddGranitNotificationsWhatsApp(opts => opts.Provider = "Brevo"); ``` ```json { "Notifications": { "Brevo": { "ApiKey": "vault:secret/data/brevo#api-key", "DefaultSenderEmail": "noreply@clinic.example.com", "DefaultSenderName": "Clinic Portal", "DefaultSmsSenderId": "CLINIC" } } } ``` ## Fan-out pattern [Section titled “Fan-out pattern”](#fan-out-pattern) A single `PublishAsync()` call produces one `DeliverNotificationCommand` per recipient per channel. The fan-out handler resolves recipients, loads user preferences, and filters out opted-out channels before dispatching. ``` sequenceDiagram participant App as Application participant Pub as INotificationPublisher participant Bus as Channel / Wolverine participant Fan as NotificationFanoutHandler participant Pref as INotificationPreferenceReader participant Del as NotificationDeliveryHandler participant Ch as INotificationChannel[] App->>Pub: PublishAsync(type, data, recipientIds) Pub->>Bus: NotificationTrigger Bus->>Fan: Handle(trigger) Fan->>Pref: IsChannelEnabledAsync(userId, type, channel) Fan-->>Bus: DeliverNotificationCommand[] (1 per user x channel) Bus->>Del: Handle(command) Del->>Ch: SendAsync(context) Del->>Del: Record NotificationDeliveryAttempt (audit) ``` ### NotificationTrigger [Section titled “NotificationTrigger”](#notificationtrigger) Published by `INotificationPublisher`. Contains the notification type name, severity, serialized data, recipient list, and optional entity reference. ```csharp public sealed record NotificationTrigger { public Guid NotificationId { get; init; } public required string NotificationTypeName { get; init; } public required NotificationSeverity Severity { get; init; } public required JsonElement Data { get; init; } public IReadOnlyList RecipientUserIds { get; init; } = []; public EntityReference? RelatedEntity { get; init; } public Guid? TenantId { get; init; } public required DateTimeOffset OccurredAt { get; init; } public string? Culture { get; init; } } ``` ### DeliverNotificationCommand [Section titled “DeliverNotificationCommand”](#delivernotificationcommand) Produced by `NotificationFanoutHandler`. One per recipient per channel. Consumed by `NotificationDeliveryHandler` which routes to the matching `INotificationChannel`. ```csharp public sealed record DeliverNotificationCommand { public required Guid DeliveryId { get; init; } public required Guid NotificationId { get; init; } public required string NotificationTypeName { get; init; } public required NotificationSeverity Severity { get; init; } public required string RecipientUserId { get; init; } public required string ChannelName { get; init; } public required JsonElement Data { get; init; } public EntityReference? RelatedEntity { get; init; } public Guid? TenantId { get; init; } public required DateTimeOffset OccurredAt { get; init; } public string? Culture { get; init; } } ``` ## INotificationPublisher [Section titled “INotificationPublisher”](#inotificationpublisher) The application-facing facade for publishing notifications. Four overloads cover explicit recipients, topic subscribers, and entity followers (Odoo-style). ```csharp public interface INotificationPublisher { // Explicit recipients ValueTask PublishAsync( NotificationType notificationType, TData data, IReadOnlyList recipientUserIds, CancellationToken cancellationToken = default) where TData : notnull; // Explicit recipients + entity reference ValueTask PublishAsync( NotificationType notificationType, TData data, IReadOnlyList recipientUserIds, EntityReference? relatedEntity, CancellationToken cancellationToken = default) where TData : notnull; // All subscribers of the notification type ValueTask PublishToSubscribersAsync( NotificationType notificationType, TData data, CancellationToken cancellationToken = default) where TData : notnull; // All followers of the entity (Odoo-style chatter) ValueTask PublishToEntityFollowersAsync( NotificationType notificationType, TData data, EntityReference relatedEntity, CancellationToken cancellationToken = default) where TData : notnull; } ``` ### Usage [Section titled “Usage”](#usage) ```csharp // 1. Define a notification type public sealed class AppointmentReminder : NotificationType { public override string Name => "Appointments.Reminder"; public override IReadOnlyList DefaultChannels => [NotificationChannels.InApp, NotificationChannels.Email, NotificationChannels.MobilePush]; } public sealed record AppointmentReminderData( Guid AppointmentId, string PatientName, DateTimeOffset ScheduledAt, string DoctorName); // 2. Register in a definition provider public class AppNotificationDefinitionProvider : INotificationDefinitionProvider { public void Define(INotificationDefinitionContext context) { context.Add(new NotificationDefinition("Appointments.Reminder") { DefaultSeverity = NotificationSeverity.Info, DefaultChannels = [NotificationChannels.InApp, NotificationChannels.Email, NotificationChannels.MobilePush], DisplayName = "Appointment Reminder", GroupName = "Appointments", AllowUserOptOut = true, }); } } // 3. Register in DI services.AddNotificationDefinitions(); // 4. Publish from application code public class AppointmentReminderService(INotificationPublisher publisher) { private static readonly AppointmentReminder Type = new(); public async Task SendReminderAsync( Appointment appointment, CancellationToken cancellationToken) { await publisher.PublishAsync( Type, new AppointmentReminderData( appointment.Id, appointment.PatientName, appointment.ScheduledAt, appointment.DoctorName), [appointment.PatientUserId], new EntityReference("Appointment", appointment.Id.ToString()), cancellationToken).ConfigureAwait(false); } } ``` ## Notification channels [Section titled “Notification channels”](#notification-channels) ### INotificationChannel [Section titled “INotificationChannel”](#inotificationchannel) Every delivery channel implements this interface. The engine resolves all registered `INotificationChannel` services and routes by `Name`: ```csharp public interface INotificationChannel { string Name { get; } Task SendAsync(NotificationDeliveryContext context, CancellationToken cancellationToken = default); } ``` Channels not registered are silently skipped with a warning log (graceful degradation pattern). ### Well-known channels [Section titled “Well-known channels”](#well-known-channels) ```csharp public static class NotificationChannels { public const string InApp = "InApp"; public const string SignalR = "SignalR"; public const string Email = "Email"; public const string Sms = "Sms"; public const string WhatsApp = "WhatsApp"; public const string Push = "Push"; // W3C Web Push public const string MobilePush = "MobilePush"; public const string Sse = "Sse"; public const string Zulip = "Zulip"; } ``` ### Channel registration [Section titled “Channel registration”](#channel-registration) | Channel | Registration | Provider resolution | | ----------- | ------------------------------------ | ---------------------------------------------------------------------------------------------- | | InApp | Built-in (auto-registered) | N/A | | Email | `AddGranitNotificationsEmail()` | Keyed Service: `"Smtp"`, `"Brevo"`, `"AzureCommunicationServices"`, `"Scaleway"`, `"SendGrid"` | | SMS | `AddGranitNotificationsSms()` | Keyed Service: `"Brevo"`, `"AzureCommunicationServices"`, `"AwsSns"`, `"Twilio"` | | WhatsApp | `AddGranitNotificationsWhatsApp()` | Keyed Service: `"Brevo"`, `"Twilio"` | | Mobile Push | `AddGranitNotificationsMobilePush()` | Keyed Service: `"GoogleFcm"`, `"AzureNotificationHubs"`, `"AwsSns"` | | SignalR | `AddGranitNotificationsSignalR()` | Direct (`NotificationHub`) | | Web Push | `AddGranitNotificationsPush()` | VAPID (Lib.Net.Http.WebPush) | | SSE | `AddGranitNotificationsSse()` | Native .NET 10 SSE | | Zulip | `AddGranitNotificationsZulip()` | Zulip Bot API | ### Implementing a custom channel [Section titled “Implementing a custom channel”](#implementing-a-custom-channel) ```csharp public class TeamsNotificationChannel(IRecipientResolver resolver) : INotificationChannel { public string Name => "Teams"; public async Task SendAsync( NotificationDeliveryContext context, CancellationToken cancellationToken = default) { RecipientInfo? recipient = await resolver .ResolveAsync(context.RecipientUserId, cancellationToken) .ConfigureAwait(false); if (recipient is null) return; // Send via Microsoft Graph API... } } // Register services.AddSingleton(); ``` ### IRecipientResolver [Section titled “IRecipientResolver”](#irecipientresolver) The application must implement this interface to resolve contact information from user identifiers. It is **not** provided by Granit — each application knows its own user model. ```csharp public interface IRecipientResolver { Task ResolveAsync( string userId, CancellationToken cancellationToken = default); } ``` ```csharp public sealed record RecipientInfo { public required string UserId { get; init; } public string? Email { get; init; } public string? PhoneNumber { get; init; } // E.164 format public string? PreferredCulture { get; init; } // BCP 47 public string? DisplayName { get; init; } } ``` ## Entity model [Section titled “Entity model”](#entity-model) ### UserNotification [Section titled “UserNotification”](#usernotification) In-app notification stored in the user’s inbox. The database is the source of truth; email/SMS/push copies are fire-and-forget. ```csharp public sealed class UserNotification : Entity, IMultiTenant { public Guid NotificationId { get; set; } public string NotificationTypeName { get; set; } public NotificationSeverity Severity { get; set; } public string RecipientUserId { get; set; } public JsonElement Data { get; set; } public UserNotificationState State { get; set; } // Unread, Read public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset? ReadAt { get; set; } public Guid? TenantId { get; set; } public string? RelatedEntityType { get; set; } public string? RelatedEntityId { get; set; } } ``` ### NotificationDeliveryAttempt [Section titled “NotificationDeliveryAttempt”](#notificationdeliveryattempt) INSERT-only audit record for ISO 27001 compliance. Never modified or deleted during the retention period. ```csharp public sealed class NotificationDeliveryAttempt : Entity { public Guid DeliveryId { get; set; } public Guid NotificationId { get; set; } public string NotificationTypeName { get; set; } public string ChannelName { get; set; } public string RecipientUserId { get; set; } public Guid? TenantId { get; set; } public DateTimeOffset OccurredAt { get; set; } public long DurationMs { get; set; } public string? ErrorMessage { get; set; } public bool IsSuccess { get; set; } } ``` ### NotificationPreference [Section titled “NotificationPreference”](#notificationpreference) User opt-in/opt-out per notification type and channel. When no preference exists, the default comes from `NotificationDefinition.DefaultChannels`. ```csharp public sealed class NotificationPreference : AuditedEntity, IMultiTenant { public string UserId { get; set; } public string NotificationTypeName { get; set; } public string ChannelName { get; set; } public bool IsEnabled { get; set; } = true; public Guid? TenantId { get; set; } } ``` ### NotificationSubscription [Section titled “NotificationSubscription”](#notificationsubscription) Topic subscription or entity follower (Odoo-style). When `EntityType` and `EntityId` are set, the subscription is an entity follower. ```csharp public sealed class NotificationSubscription : CreationAuditedEntity, IMultiTenant { public string UserId { get; set; } public string NotificationTypeName { get; set; } public Guid? TenantId { get; set; } public string? EntityType { get; set; } // null = topic subscription public string? EntityId { get; set; } // null = topic subscription } ``` ## CQRS reader/writer pairs [Section titled “CQRS reader/writer pairs”](#cqrs-readerwriter-pairs) All persistence follows the CQRS pattern with separate reader and writer interfaces. The core package registers in-memory defaults; `Granit.Notifications.EntityFrameworkCore` replaces them with EF Core implementations. | Reader | Writer | Store | | --------------------------------- | --------------------------------- | --------------------------------- | | `IUserNotificationReader` | `IUserNotificationWriter` | Inbox (UserNotification) | | `INotificationPreferenceReader` | `INotificationPreferenceWriter` | Preferences | | `INotificationSubscriptionReader` | `INotificationSubscriptionWriter` | Subscriptions + entity followers | | `IMobilePushTokenReader` | `IMobilePushTokenWriter` | Device tokens | | — | `INotificationDeliveryWriter` | Delivery audit trail (write-only) | ```csharp // Read interfaces — inject only what you need (ISP) public interface IUserNotificationReader { Task GetAsync(Guid id, CancellationToken ct = default); Task> GetListAsync( string recipientUserId, Guid? tenantId, int page = 1, int pageSize = 20, CancellationToken ct = default); Task GetUnreadCountAsync( string recipientUserId, Guid? tenantId, CancellationToken ct = default); Task> GetByEntityAsync( string entityType, string entityId, Guid? tenantId, int page = 1, int pageSize = 20, CancellationToken ct = default); } public interface IUserNotificationWriter { Task InsertAsync(UserNotification notification, CancellationToken ct = default); Task MarkAsReadAsync(Guid id, DateTimeOffset readAt, CancellationToken ct = default); Task MarkAllAsReadAsync( string recipientUserId, Guid? tenantId, DateTimeOffset readAt, CancellationToken ct = default); } ``` ## Notification definitions [Section titled “Notification definitions”](#notification-definitions) Notification types are declared at startup via `INotificationDefinitionProvider` and stored in `INotificationDefinitionStore` (singleton). The fan-out handler uses definitions to determine default channels and whether user opt-out is allowed. ```csharp public sealed class NotificationDefinition { public string Name { get; } public NotificationSeverity DefaultSeverity { get; init; } = NotificationSeverity.Info; public IReadOnlyList DefaultChannels { get; init; } = []; public string? DisplayName { get; init; } public string? Description { get; init; } public string? GroupName { get; init; } public bool AllowUserOptOut { get; init; } = true; } ``` Caution When `AllowUserOptOut` is `false`, the notification is **always** delivered regardless of user preferences. Use this for security alerts, GDPR breach notifications, and regulatory communications only. ## Notification preferences [Section titled “Notification preferences”](#notification-preferences) Users can opt in/out per notification type per channel. The fan-out handler checks `INotificationPreferenceReader.IsChannelEnabledAsync()` before producing delivery commands. ``` flowchart TD T[NotificationTrigger] --> F{AllowUserOptOut?} F -->|No| D[Deliver to all channels] F -->|Yes| P{User preference?} P -->|Enabled / No pref| D P -->|Disabled| S[Skip channel] ``` The `GET /notifications/preferences` endpoint returns all preferences, and `PUT /notifications/preferences` creates or updates a preference. The `GET /notifications/types` endpoint lists all registered notification definitions, enabling UIs to build a preference matrix. ## Entity tracking (Odoo-style chatter) [Section titled “Entity tracking (Odoo-style chatter)”](#entity-tracking-odoo-style-chatter) Entities implementing `ITrackedEntity` automatically generate notifications to followers when tracked properties change. The `EntityTrackingInterceptor` (in `Granit.Notifications.EntityFrameworkCore`) detects changes during `SaveChanges`. ```csharp public interface ITrackedEntity { static abstract string EntityTypeName { get; } string GetEntityId(); static abstract IReadOnlyDictionary TrackedProperties { get; } } public sealed record TrackedPropertyConfig { public required string NotificationTypeName { get; init; } public NotificationSeverity Severity { get; init; } = NotificationSeverity.Info; } ``` ```csharp public class Patient : AuditedAggregateRoot, ITrackedEntity { public static string EntityTypeName => "Patient"; public string GetEntityId() => Id.ToString(); public static IReadOnlyDictionary TrackedProperties { get; } = new Dictionary { ["Status"] = new() { NotificationTypeName = "Patient.StatusChanged" }, ["AssignedDoctorId"] = new() { NotificationTypeName = "Patient.DoctorReassigned", Severity = NotificationSeverity.Warning, }, }; public string Status { get; set; } = string.Empty; public Guid? AssignedDoctorId { get; set; } } ``` When `Patient.Status` changes, the interceptor publishes an `EntityStateChangedData` notification to all followers of that patient. ## Endpoints [Section titled “Endpoints”](#endpoints) `Granit.Notifications.Endpoints` maps all endpoints under the `/notifications` prefix (configurable via `NotificationEndpointsOptions.RoutePrefix`). All endpoints require authentication. ### Inbox [Section titled “Inbox”](#inbox) | Method | Route | Description | | ------ | ----------------------------- | ----------------------------------------------- | | GET | `/notifications` | User’s notification inbox (paged, newest first) | | GET | `/notifications/unread/count` | Unread notification count | | POST | `/notifications/{id}/read` | Mark a single notification as read | | POST | `/notifications/read-all` | Mark all notifications as read | ### Activity feed [Section titled “Activity feed”](#activity-feed) | Method | Route | Description | | ------ | ----------------------------------------------- | ----------------------------------- | | GET | `/notifications/entity/{entityType}/{entityId}` | Activity feed for a specific entity | ### Preferences [Section titled “Preferences”](#preferences) | Method | Route | Description | | ------ | ---------------------------- | ------------------------------------------------- | | GET | `/notifications/preferences` | User’s delivery preferences | | PUT | `/notifications/preferences` | Create or update a preference | | GET | `/notifications/types` | List all registered notification type definitions | ### Subscriptions [Section titled “Subscriptions”](#subscriptions) | Method | Route | Description | | ------ | ----------------------------------------- | ------------------------------------ | | GET | `/notifications/subscriptions` | User’s topic subscriptions | | POST | `/notifications/subscriptions/{typeName}` | Subscribe to a notification type | | DELETE | `/notifications/subscriptions/{typeName}` | Unsubscribe from a notification type | ### Entity followers [Section titled “Entity followers”](#entity-followers) | Method | Route | Description | | ------ | --------------------------------------------------------- | --------------------- | | POST | `/notifications/entity/{entityType}/{entityId}/follow` | Follow an entity | | DELETE | `/notifications/entity/{entityType}/{entityId}/follow` | Unfollow an entity | | GET | `/notifications/entity/{entityType}/{entityId}/followers` | List entity followers | ### Mobile push tokens [Section titled “Mobile push tokens”](#mobile-push-tokens) Mapped separately via `MapMobilePushTokenEndpoints()` under `api/notifications/mobile-push/tokens`: | Method | Route | Description | | ------ | ----------------------------------------------------- | --------------------------------- | | POST | `/api/notifications/mobile-push/tokens` | Register a device token | | DELETE | `/api/notifications/mobile-push/tokens/{deviceToken}` | Remove a device token | | GET | `/api/notifications/mobile-push/tokens` | List current user’s device tokens | ## Wolverine integration [Section titled “Wolverine integration”](#wolverine-integration) `Granit.Notifications.Wolverine` replaces the default in-process `Channel` publisher with a durable `IMessageBus`-backed implementation. Notifications are persisted in the Wolverine outbox and survive application crashes. ```csharp [DependsOn(typeof(GranitNotificationsWolverineModule))] public class AppModule : GranitModule { } ``` The module configures two local queues: | Queue | Message | Behavior | | ----------------------- | ---------------------------- | ----------------------------------- | | `notification-fanout` | `NotificationTrigger` | Default parallelism | | `notification-delivery` | `DeliverNotificationCommand` | `MaxParallelDeliveries` (default 8) | Retry policy on `NotificationDeliveryException`: | Attempt | Delay | | ------- | ---------- | | 1 | 10 seconds | | 2 | 1 minute | | 3 | 5 minutes | | 4 | 30 minutes | | 5 | 2 hours | ## Configuration reference [Section titled “Configuration reference”](#configuration-reference) ### Core [Section titled “Core”](#core) ```json { "Notifications": { "MaxParallelDeliveries": 8 } } ``` | Property | Default | Description | | ----------------------- | ------- | -------------------------------------------------------------- | | `MaxParallelDeliveries` | `8` | Max concurrent delivery messages (Wolverine queue parallelism) | ### Email [Section titled “Email”](#email) ```json { "Notifications": { "Email": { "Provider": "Smtp", "SenderAddress": "noreply@clinic.example.com", "SenderName": "Clinic Portal" } } } ``` ### SMTP [Section titled “SMTP”](#smtp) ```json { "Notifications": { "Smtp": { "Host": "smtp.example.com", "Port": 587, "UseSsl": true, "Username": "user", "Password": "vault:secret/data/smtp#password", "TimeoutSeconds": 30 } } } ``` ### Brevo [Section titled “Brevo”](#brevo) ```json { "Notifications": { "Brevo": { "ApiKey": "vault:secret/data/brevo#api-key", "DefaultSenderEmail": "noreply@clinic.example.com", "DefaultSenderName": "Clinic Portal", "DefaultSmsSenderId": "CLINIC", "BaseUrl": "https://api.brevo.com/v3", "TimeoutSeconds": 30 } } } ``` ### SendGrid [Section titled “SendGrid”](#sendgrid) ```json { "Notifications": { "Email": { "SendGrid": { "ApiKey": "vault:secret/data/sendgrid#api-key", "DefaultSenderEmail": "noreply@clinic.example.com", "DefaultSenderName": "Clinic Portal", "SandboxMode": false, "TimeoutSeconds": 30 } } } } ``` ### Twilio [Section titled “Twilio”](#twilio) ```json { "Notifications": { "Twilio": { "AccountSid": "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "AuthToken": "vault:secret/data/twilio#auth-token", "DefaultSmsFrom": "+15551234567", "DefaultWhatsAppFrom": "whatsapp:+14155238886", "MessagingServiceSid": null, "TimeoutSeconds": 30 } } } ``` ### SMS [Section titled “SMS”](#sms) ```json { "Notifications": { "Sms": { "Provider": "Brevo", "SenderId": "CLINIC" } } } ``` ### Mobile Push (FCM) [Section titled “Mobile Push (FCM)”](#mobile-push-fcm) ```json { "Notifications": { "MobilePush": { "Provider": "GoogleFcm", "GoogleFcm": { "ProjectId": "my-firebase-project", "ServiceAccountJson": "vault:secret/data/fcm#service-account", "BaseAddress": "https://fcm.googleapis.com/", "TimeoutSeconds": 30 } } } } ``` Caution FCM push data payloads must NOT contain PII or health data. The push serves as a wake-up signal only — the app fetches details from the API after receiving the push (ISO 27001 compliance). ### Azure Communication Services — Email [Section titled “Azure Communication Services — Email”](#azure-communication-services--email) ```json { "AzureCommunicationServices": { "Email": { "ConnectionString": "endpoint=...;accesskey=...", "SenderAddress": "noreply@mydomain.com", "TimeoutSeconds": 120 } } } ``` ### Azure Communication Services — SMS [Section titled “Azure Communication Services — SMS”](#azure-communication-services--sms) ```json { "AzureCommunicationServices": { "Sms": { "ConnectionString": "endpoint=...;accesskey=...", "FromPhoneNumber": "+15551234567", "TimeoutSeconds": 30 } } } ``` ### AWS SNS — SMS [Section titled “AWS SNS — SMS”](#aws-sns--sms) ```json { "Notifications": { "Sms": { "AwsSns": { "Region": "eu-west-1", "SenderId": "MYAPP", "SmsType": "Transactional", "TimeoutSeconds": 30 } } } } ``` | Property | Default | Description | | ------------------- | ----------------- | -------------------------------------------------------- | | `Region` | — | AWS region (required) | | `SenderId` | `null` | Alphanumeric sender ID (1-11 chars, region-dependent) | | `SmsType` | `"Transactional"` | `"Transactional"` or `"Promotional"` | | `OriginationNumber` | `null` | E.164 originating number | | `AccessKeyId` | `null` | AWS access key (uses default credential chain when null) | | `SecretAccessKey` | `null` | AWS secret key (required if AccessKeyId set) | ### AWS SNS — Mobile Push [Section titled “AWS SNS — Mobile Push”](#aws-sns--mobile-push) ```json { "Notifications": { "MobilePush": { "AwsSns": { "Region": "eu-west-1", "PlatformApplicationArn": "arn:aws:sns:eu-west-1:123456789:app/GCM/my-app", "TimeoutSeconds": 30 } } } } ``` | Property | Default | Description | | ------------------------ | ------- | -------------------------------------------------------- | | `Region` | — | AWS region (required) | | `PlatformApplicationArn` | — | SNS Platform Application ARN (required) | | `AccessKeyId` | `null` | AWS access key (uses default credential chain when null) | | `SecretAccessKey` | `null` | AWS secret key (required if AccessKeyId set) | Caution Like FCM, push data payloads via AWS SNS must NOT contain PII or health data. The push serves as a wake-up signal only (ISO 27001 compliance). ### Azure Notification Hubs — Mobile Push [Section titled “Azure Notification Hubs — Mobile Push”](#azure-notification-hubs--mobile-push) ```json { "Notifications": { "AzureNotificationHubs": { "ConnectionString": "Endpoint=sb://...", "HubName": "my-notification-hub", "TimeoutSeconds": 30 } } } ``` Caution Like FCM, push data payloads via Azure Notification Hubs must NOT contain PII or health data. The push serves as a wake-up signal only (ISO 27001 compliance). ### SignalR [Section titled “SignalR”](#signalr) ```json { "Notifications": { "SignalR": { "RedisConnectionString": "redis:6379" } } } ``` ### Web Push (VAPID) [Section titled “Web Push (VAPID)”](#web-push-vapid) ```json { "Notifications": { "Push": { "VapidSubject": "mailto:admin@clinic.example.com", "VapidPublicKey": "BFx...", "VapidPrivateKey": "vault:secret/data/webpush#private-key" } } } ``` ### SSE [Section titled “SSE”](#sse) ```json { "Notifications": { "Sse": { "HeartbeatIntervalSeconds": 30 } } } ``` ### Zulip [Section titled “Zulip”](#zulip) ```json { "Notifications": { "Zulip": { "DefaultStream": "alerts", "DefaultTopic": "system", "Bot": { "BaseUrl": "https://zulip.example.com", "BotEmail": "notification-bot@zulip.example.com", "ApiKey": "vault:secret/data/zulip#api-key", "TimeoutSeconds": 30 } } } } ``` ## Health checks [Section titled “Health checks”](#health-checks) Notification channel providers register opt-in health checks: ```csharp builder.Services.AddHealthChecks() .AddGranitSmtpHealthCheck() .AddGranitAwsSesHealthCheck() .AddGranitBrevoHealthCheck() .AddGranitAcsEmailHealthCheck() .AddGranitAcsSmsHealthCheck() .AddGranitAzureNotificationHubsHealthCheck() .AddGranitAwsSnsSmsHealthCheck() .AddGranitAwsSnsMobilePushHealthCheck() .AddGranitScalewayEmailHealthCheck() .AddGranitSendGridHealthCheck() .AddGranitTwilioHealthCheck() .AddGranitZulipHealthCheck(); ``` | Provider | Extension | Probe | Tags | | ----------------------- | --------------------------------------------- | ------------------------------------------- | ------------------ | | SMTP | `AddGranitSmtpHealthCheck()` | EHLO handshake via MailKit | readiness | | SES | `AddGranitAwsSesHealthCheck()` | `GetAccount()` — Degraded if sending paused | readiness, startup | | Brevo | `AddGranitBrevoHealthCheck()` | `GET /account` | readiness | | ACS Email | `AddGranitAcsEmailHealthCheck()` | `SendAsync` probe | readiness | | ACS SMS | `AddGranitAcsSmsHealthCheck()` | `SendAsync` probe | readiness | | Azure Notification Hubs | `AddGranitAzureNotificationHubsHealthCheck()` | Hub description retrieval | readiness | | SNS SMS | `AddGranitAwsSnsSmsHealthCheck()` | SNS API connectivity check | readiness, startup | | SNS Mobile Push | `AddGranitAwsSnsMobilePushHealthCheck()` | SNS Platform Application check | readiness, startup | | Scaleway TEM | `AddGranitScalewayEmailHealthCheck()` | GET /emails?page\_size=1 | readiness, startup | | SendGrid | `AddGranitSendGridHealthCheck()` | `GET /scopes` | readiness, startup | | Twilio | `AddGranitTwilioHealthCheck()` | `GET /Accounts/{sid}.json` | readiness, startup | | Zulip | `AddGranitZulipHealthCheck()` | `GET /api/v1/users/me` (Bot auth) | readiness | All checks sanitize error messages — credentials, hostnames, and API keys are never exposed in the health check response. Every check enforces a 10-second defensive timeout via `.WaitAsync()` to prevent blocking Kubernetes probe cycles. ## OpenTelemetry [Section titled “OpenTelemetry”](#opentelemetry) All fan-out and delivery operations are traced via `ActivitySource`: | Activity name | Description | | ----------------------- | --------------------------------------------------------------- | | `notifications.fanout` | Fan-out of a `NotificationTrigger` into delivery commands | | `notifications.deliver` | Delivery of a single `DeliverNotificationCommand` via a channel | Tags: `notifications.type`, `notifications.channel`, `notifications.delivery_id`, `notifications.notification_id`, `notifications.recipient_count`, `notifications.delivery_count`, `notifications.success`. ## Public API summary [Section titled “Public API summary”](#public-api-summary) | Category | Key types | Package | | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Module | `GranitNotificationsModule`, `GranitNotificationsEntityFrameworkCoreModule`, `GranitNotificationsWolverineModule` | — | | Publisher | `INotificationPublisher`, `NotificationType` | `Granit.Notifications` | | Channels | `INotificationChannel`, `NotificationChannels`, `NotificationDeliveryContext` | `Granit.Notifications` | | Definitions | `NotificationDefinition`, `INotificationDefinitionProvider`, `INotificationDefinitionContext`, `INotificationDefinitionStore` | `Granit.Notifications` | | Entities | `UserNotification`, `NotificationDeliveryAttempt`, `NotificationPreference`, `NotificationSubscription`, `UserNotificationState` | `Granit.Notifications` | | CQRS | `IUserNotificationReader`, `IUserNotificationWriter`, `INotificationPreferenceReader`, `INotificationPreferenceWriter`, `INotificationSubscriptionReader`, `INotificationSubscriptionWriter`, `INotificationDeliveryWriter` | `Granit.Notifications` | | Recipient | `IRecipientResolver`, `RecipientInfo` | `Granit.Notifications` | | Entity tracking | `ITrackedEntity`, `TrackedPropertyConfig`, `EntityStateChangedData`, `EntityReference` | `Granit.Notifications` | | Messages | `NotificationTrigger`, `DeliverNotificationCommand` | `Granit.Notifications` | | Handlers | `NotificationFanoutHandler`, `NotificationDeliveryHandler` | `Granit.Notifications` | | Options | `NotificationsOptions`, `EmailChannelOptions`, `SmtpOptions`, `BrevoOptions`, `ScalewayEmailOptions`, `SendGridEmailOptions`, `AcsEmailOptions`, `SmsChannelOptions`, `AcsSmsOptions`, `AwsSnsSmsOptions`, `TwilioOptions`, `MobilePushChannelOptions`, `GoogleFcmOptions`, `AzureNotificationHubsOptions`, `AwsSnsMobilePushOptions`, `SignalRChannelOptions`, `PushChannelOptions`, `SseChannelOptions`, `ZulipChannelOptions`, `ZulipBotOptions` | various | | Email | `IEmailSender`, `EmailMessage` | `Granit.Notifications.Email`, `Granit.Notifications.Email.AzureCommunicationServices`, `Granit.Notifications.Email.Scaleway`, `Granit.Notifications.Email.SendGrid` | | SMS | `ISmsSender`, `SmsMessage` | `Granit.Notifications.Sms`, `Granit.Notifications.Sms.AwsSns`, `Granit.Notifications.Twilio` | | WhatsApp | `IWhatsAppSender`, `WhatsAppMessage` | `Granit.Notifications.WhatsApp`, `Granit.Notifications.Twilio` | | Mobile Push | `IMobilePushSender`, `MobilePushMessage`, `IMobilePushTokenReader`, `IMobilePushTokenWriter`, `MobilePushTokenInfo`, `MobilePlatform` | `Granit.Notifications.MobilePush`, `Granit.Notifications.MobilePush.AwsSns` | | SignalR | `NotificationHub`, `SignalRNotificationMessage` | `Granit.Notifications.SignalR` | | Web Push | `IPushSubscriptionReader`, `IPushSubscriptionWriter`, `PushSubscriptionInfo` | `Granit.Notifications.WebPush` | | SSE | `ISseConnectionManager`, `SseConnection`, `SseNotificationMessage` | `Granit.Notifications.Sse` | | Zulip | `IZulipSender`, `ZulipMessage` | `Granit.Notifications.Zulip` | | Exceptions | `NotificationDeliveryException` | `Granit.Notifications` | | Endpoints | `NotificationEndpointsOptions`, `MobilePushTokenEndpoints` | `Granit.Notifications.Endpoints` | | Extensions | `AddGranitNotifications()`, `AddGranitNotificationsEntityFrameworkCore()`, `AddGranitNotificationsEmail()`, `AddGranitNotificationsEmailSmtp()`, `AddGranitNotificationsEmailAcs()`, `AddGranitNotificationsEmailScaleway()`, `AddGranitNotificationsEmailSendGrid()`, `AddGranitNotificationsBrevo()`, `AddGranitNotificationsSms()`, `AddGranitNotificationsSmsAcs()`, `AddGranitNotificationsSmsAwsSns()`, `AddGranitNotificationsTwilio()`, `AddGranitNotificationsWhatsApp()`, `AddGranitNotificationsMobilePush()`, `AddGranitNotificationsMobilePushGoogleFcm()`, `AddGranitNotificationsMobilePushAzureNotificationHubs()`, `AddGranitNotificationsMobilePushAwsSns()`, `AddGranitNotificationsSignalR()`, `AddGranitNotificationsPush()`, `AddGranitNotificationsSse()`, `AddGranitNotificationsZulip()`, `AddNotificationDefinitions()`, `MapGranitNotificationEndpoints()`, `MapMobilePushTokenEndpoints()` | various | ## See also [Section titled “See also”](#see-also) * [Wolverine module](./wolverine/) — Durable messaging, transactional outbox * [Persistence module](./persistence/) — `AuditedEntity`, `Entity`, interceptors * [Identity module](./identity/) — User lookup for `IRecipientResolver` implementation * [Templating module](./templating/) — Scriban templates for email/SMS content * [Core module](./core/) — `Entity`, `AuditedEntity`, `IMultiTenant` * [API Reference](/api/Granit.Notifications.html) (auto-generated from XML docs) # Granit.Observability & Diagnostics > Serilog + OpenTelemetry → OTLP, Kubernetes health probes (liveness, readiness, startup) Granit.Observability wires Serilog structured logging and OpenTelemetry (traces + metrics) into a single `AddGranitObservability()` call. Granit.Diagnostics adds Kubernetes-native health check endpoints with stampede-protected caching and a JSON response writer for Grafana dashboards. ## Package structure [Section titled “Package structure”](#package-structure) * Granit.Observability Serilog + OpenTelemetry OTLP export * Granit.Diagnostics Kubernetes health probes (liveness, readiness, startup) | Package | Role | Depends on | | ---------------------- | ------------------------------------------------ | --------------- | | `Granit.Observability` | Serilog + OpenTelemetry (traces, metrics, logs) | `Granit.Core` | | `Granit.Diagnostics` | Health check endpoints, response writer, caching | `Granit.Timing` | ## OTLP pipeline [Section titled “OTLP pipeline”](#otlp-pipeline) ``` graph LR App[ASP.NET Core App] --> Serilog App --> OTel[OpenTelemetry SDK] Serilog -->|WriteTo.OpenTelemetry| Collector[OTLP Collector :4317] OTel -->|OTLP gRPC| Collector Collector --> Loki[Loki — Logs] Collector --> Tempo[Tempo — Traces] Collector --> Mimir[Mimir — Metrics] Loki --> Grafana Tempo --> Grafana Mimir --> Grafana ``` ## Setup [Section titled “Setup”](#setup) * Observability ```csharp [DependsOn(typeof(GranitObservabilityModule))] public class AppModule : GranitModule { } ``` ```json { "Observability": { "ServiceName": "my-backend", "ServiceVersion": "1.2.0", "OtlpEndpoint": "http://otel-collector:4317", "ServiceNamespace": "my-company", "Environment": "production" } } ``` * Diagnostics ```csharp [DependsOn(typeof(GranitDiagnosticsModule))] public class AppModule : GranitModule { } ``` In `Program.cs`, after `app.Build()`: ```csharp app.MapGranitHealthChecks(); ``` * Both (typical) ```csharp [DependsOn( typeof(GranitObservabilityModule), typeof(GranitDiagnosticsModule))] public class AppModule : GranitModule { } ``` ```csharp var app = builder.Build(); app.MapGranitHealthChecks(); app.Run(); ``` ## Serilog configuration [Section titled “Serilog configuration”](#serilog-configuration) `AddGranitObservability()` configures two Serilog sinks: | Sink | Purpose | | --------------- | --------------------------------------------------------- | | `Console` | Local development, `[HH:mm:ss LEV] SourceContext Message` | | `OpenTelemetry` | OTLP export to Loki via the collector | Every log entry is enriched with `ServiceName`, `ServiceVersion`, and `Environment` properties, matching the OpenTelemetry resource attributes for correlation. Additional Serilog settings (minimum level, overrides, extra sinks) can be added via standard `Serilog` configuration in `appsettings.json` — `ReadFrom.Configuration` is called before the Granit enrichers. ## OpenTelemetry instrumentation [Section titled “OpenTelemetry instrumentation”](#opentelemetry-instrumentation) Three built-in instrumentations are registered automatically: | Instrumentation | What it captures | | --------------- | -------------------------------------------------- | | ASP.NET Core | Inbound HTTP requests (method, route, status code) | | HttpClient | Outbound HTTP calls (dependency tracking) | | EF Core | Database queries (command text, duration) | Health check endpoints (`/health/*`) are filtered out of traces to avoid noise. ### Activity source auto-registration [Section titled “Activity source auto-registration”](#activity-source-auto-registration) Granit modules register their own `ActivitySource` names via `GranitActivitySourceRegistry.Register()` during host configuration. `AddGranitObservability()` reads the registry and calls `AddSource()` for each — no manual wiring needed. ```csharp // Inside a module's AddGranit*() extension method GranitActivitySourceRegistry.Register("Granit.Workflow"); ``` ### Registered activity sources [Section titled “Registered activity sources”](#registered-activity-sources) Modules that create spans register their `ActivitySource` name at startup. The table below lists every source and the span names it emits. | ActivitySource | Span names | | ------------------------------------------------------- | ---------------------------------------------------------------------------- | | `Granit.Vault` | `vault.encrypt`, `vault.decrypt`, `vault.get-secret`, `vault.check-rotation` | | `Granit.Vault.Azure` | `akv.encrypt`, `akv.decrypt`, `akv.get-secret`, `akv.check-rotation` | | `Granit.Wolverine` | `wolverine.send`, `wolverine.handle` | | `Granit.Notifications` | `notification.dispatch`, `notification.deliver` | | `Granit.Notifications.Email.Smtp` | `smtp.send` | | `Granit.Notifications.Email.AwsSes` | `ses.send` | | `Granit.Notifications.Email.AzureCommunicationServices` | `acs-email.send` | | `Granit.Notifications.Sms.AzureCommunicationServices` | `acs-sms.send` | | `Granit.Notifications.MobilePush.AzureNotificationHubs` | `anh.send` | | `Granit.Notifications.Brevo` | `brevo.send` | | `Granit.Notifications.Zulip` | `zulip.send` | | `Granit.Workflow` | `workflow.transition` | | `Granit.BlobStorage` | `blob.upload`, `blob.download`, `blob.delete` | | `Granit.DataExchange` | `import.execute`, `export.execute` | ## Kubernetes health probes [Section titled “Kubernetes health probes”](#kubernetes-health-probes) `MapGranitHealthChecks()` registers three endpoints, all `AllowAnonymous` (the kubelet cannot authenticate): | Probe | Path | Behavior | Failure effect | | --------- | ----------------- | ------------------------------------------- | ----------------------------------------- | | Liveness | `/health/live` | Always returns `200` — no dependency checks | Pod restart | | Readiness | `/health/ready` | Checks tagged `"readiness"` | Pod removed from load balancer | | Startup | `/health/startup` | Checks tagged `"startup"` | Liveness/readiness disabled until healthy | ### Status code mapping (readiness and startup) [Section titled “Status code mapping (readiness and startup)”](#status-code-mapping-readiness-and-startup) | HealthStatus | HTTP | Effect | | ------------ | ----- | ----------------------------------------------------- | | `Healthy` | `200` | Pod receives traffic | | `Degraded` | `200` | Pod stays in load balancer (non-critical degradation) | | `Unhealthy` | `503` | Pod removed from load balancer | ### Built-in health checks [Section titled “Built-in health checks”](#built-in-health-checks) Granit modules provide opt-in health checks via `AddGranit*HealthCheck()` extension methods on `IHealthChecksBuilder`. Each check follows the same pattern: sanitized error messages (never exposing credentials), structured `data` where applicable, and appropriate tags for Kubernetes probes. | Module | Extension method | Probe | Tags | | ------------------------------------------------------- | --------------------------------------------- | ----------------------------------------------- | ------------------ | | `Granit.Persistence` | `AddGranitDbContextHealthCheck()` | EF Core `CanConnectAsync` | readiness, startup | | `Granit.Caching.StackExchangeRedis` | `AddGranitRedisHealthCheck()` | Redis `PING` with latency threshold | readiness, startup | | `Granit.Vault` | `AddGranitVaultHealthCheck()` | Vault seal status + auth | readiness, startup | | `Granit.Vault.Aws` | `AddGranitKmsHealthCheck()` | KMS `DescribeKey` (Degraded on PendingDeletion) | readiness, startup | | `Granit.Identity.Keycloak` | `AddGranitKeycloakHealthCheck()` | `client_credentials` token request | readiness, startup | | `Granit.Identity.EntraId` | `AddGranitEntraIdHealthCheck()` | `client_credentials` token request | readiness, startup | | `Granit.BlobStorage.S3` | `AddGranitS3HealthCheck()` | `ListObjectsV2(MaxKeys=1)` | readiness, startup | | `Granit.Notifications.Email.Smtp` | `AddGranitSmtpHealthCheck()` | EHLO handshake via MailKit | readiness | | `Granit.Notifications.Email.AwsSes` | `AddGranitAwsSesHealthCheck()` | `GetAccount()` (Degraded if sending paused) | readiness, startup | | `Granit.Notifications.Brevo` | `AddGranitBrevoHealthCheck()` | `GET /account` | readiness | | `Granit.Notifications.Zulip` | `AddGranitZulipHealthCheck()` | `GET /api/v1/users/me` | readiness | | `Granit.Vault.Azure` | `AddGranitAzureKeyVaultHealthCheck()` | GetKey probe | readiness | | `Granit.Notifications.Email.AzureCommunicationServices` | `AddGranitAcsEmailHealthCheck()` | Send probe | readiness | | `Granit.Notifications.Sms.AzureCommunicationServices` | `AddGranitAcsSmsHealthCheck()` | Send probe | readiness | | `Granit.Notifications.MobilePush.AzureNotificationHubs` | `AddGranitAzureNotificationHubsHealthCheck()` | Hub description | readiness | ### Defensive timeout [Section titled “Defensive timeout”](#defensive-timeout) All built-in health checks wrap their external call with `.WaitAsync(10s, cancellationToken)`. If the dependency does not respond within 10 seconds, the check returns `Unhealthy` immediately instead of blocking the Kubernetes probe cycle. ### Registering health checks [Section titled “Registering health checks”](#registering-health-checks) Tag your health checks with `"readiness"` and/or `"startup"` so they are picked up by the correct probe. Use the built-in extension methods when available: ```csharp builder.Services.AddHealthChecks() .AddGranitDbContextHealthCheck() .AddGranitRedisHealthCheck(degradedThreshold: TimeSpan.FromMilliseconds(100)) .AddGranitKeycloakHealthCheck() .AddGranitAwsSesHealthCheck() .AddGranitBrevoHealthCheck() .AddGranitZulipHealthCheck() .AddGranitAzureKeyVaultHealthCheck() .AddGranitAcsEmailHealthCheck() .AddGranitAcsSmsHealthCheck() .AddGranitAzureNotificationHubsHealthCheck(); ``` ### JSON response format [Section titled “JSON response format”](#json-response-format) `GranitHealthCheckWriter` produces a structured JSON payload for Grafana/Loki dashboards. Kubernetes only reads the HTTP status code; the body is for operations teams. ```json { "status": "Healthy", "duration": 12.3, "checks": [ { "name": "database", "status": "Healthy", "duration": 8.1, "tags": ["readiness", "startup"] } ] } ``` Caution The response never contains stack traces, connection strings, tokens, or PII (ISO 27001 / GDPR compliance). ## Health check caching [Section titled “Health check caching”](#health-check-caching) `CachedHealthCheck` wraps any `IHealthCheck` with a SemaphoreSlim double-check locking pattern to prevent stampede when many pods are probed simultaneously. ``` sequenceDiagram participant P1 as Probe 1 participant P2 as Probe 2 participant C as CachedHealthCheck participant DB as Dependency P1->>C: CheckHealthAsync() P2->>C: CheckHealthAsync() C->>C: Cache expired C->>C: Acquire SemaphoreSlim Note over P2,C: P2 waits (lock held) C->>DB: inner.CheckHealthAsync() DB-->>C: Healthy C->>C: Cache result (10s default) C->>C: Release lock C-->>P1: Healthy C->>C: Double-check → cache hit C-->>P2: Healthy (from cache) ``` With 50 pods probed every 3 seconds, an uncached database check generates \~16 req/s. The cache reduces that to 1 request per `DefaultCacheDuration` per pod. ## Configuration reference [Section titled “Configuration reference”](#configuration-reference) ### ObservabilityOptions section: Observability [Section titled “ObservabilityOptions ”](#observabilityoptions-) | Property | Type | Default | Description | | ------------------ | -------- | ------------------------- | ------------------------------ | | `ServiceName` | `string` | `"unknown-service"` | Service name for OTEL resource | | `ServiceVersion` | `string` | `"0.0.0"` | Service version | | `OtlpEndpoint` | `string` | `"http://localhost:4317"` | OTLP gRPC endpoint | | `ServiceNamespace` | `string` | `"my-company"` | Service namespace | | `Environment` | `string` | `"development"` | Deployment environment | | `EnableTracing` | `bool` | `true` | Enable trace export via OTLP | | `EnableMetrics` | `bool` | `true` | Enable metrics export via OTLP | ### DiagnosticsOptions section: DiagnosticsOptions [Section titled “DiagnosticsOptions ”](#diagnosticsoptions-) | Property | Type | Default | Description | | ---------------------- | ---------- | ------------------- | --------------------------------- | | `LivenessPath` | `string` | `"/health/live"` | Liveness probe endpoint path | | `ReadinessPath` | `string` | `"/health/ready"` | Readiness probe endpoint path | | `StartupPath` | `string` | `"/health/startup"` | Startup probe endpoint path | | `DefaultCacheDuration` | `TimeSpan` | `00:00:10` | Cache TTL for `CachedHealthCheck` | ## Public API summary [Section titled “Public API summary”](#public-api-summary) | Category | Key types | Package | | ---------------- | ------------------------------------------------------------------------------- | -------------------- | | Modules | `GranitObservabilityModule`, `GranitDiagnosticsModule` | — | | Options | `ObservabilityOptions`, `DiagnosticsOptions` | — | | Health checks | `CachedHealthCheck`, `GranitHealthCheckWriter` | `Granit.Diagnostics` | | Activity sources | `GranitActivitySourceRegistry` | `Granit.Core` | | Extensions | `AddGranitObservability()`, `AddGranitDiagnostics()`, `MapGranitHealthChecks()` | — | ## See also [Section titled “See also”](#see-also) * [Caching module](./caching/) — Redis health check tagged `"readiness"` * [Identity module](./identity/) — Keycloak and Entra ID health checks * [Notifications module](./notifications/) — SMTP, SES, Brevo, and Zulip health checks * [Blob Storage module](./blob-storage/) — S3 health check * [Vault & Encryption module](./vault-encryption/) — Vault and KMS health checks * [Core module](./core/) — `GranitActivitySourceRegistry` lives in `Granit.Core.Diagnostics` # Granit.Persistence > EF Core interceptors, audit trail, soft delete, data seeding, zero-downtime migrations Granit.Persistence provides the data access layer for the framework. It automates ISO 27001 audit trails, GDPR-compliant soft delete, domain event dispatching, entity versioning, and multi-tenant query filtering — all through EF Core interceptors and conventions. No manual `WHERE` clauses, no forgotten audit fields. ## Package structure [Section titled “Package structure”](#package-structure) | Package | Role | Depends on | | ----------------------------------------- | ---------------------------------------------------- | ----------------------------------------------------------------- | | `Granit.Persistence` | Interceptors, `ApplyGranitConventions`, data seeding | `Granit.Core`, `Granit.Timing`, `Granit.Guids`, `Granit.Security` | | `Granit.Persistence.Migrations` | Expand & Contract migrations with batch processing | `Granit.Persistence`, `Granit.Timing` | | `Granit.Persistence.Migrations.Wolverine` | Durable migration dispatch via Wolverine outbox | `Granit.Persistence.Migrations`, `Granit.Wolverine` | ## Dependency graph [Section titled “Dependency graph”](#dependency-graph) ``` graph TD P[Granit.Persistence] --> C[Granit.Core] P --> T[Granit.Timing] P --> G[Granit.Guids] P --> S[Granit.Security] PM[Granit.Persistence.Migrations] --> P PM --> T PMW[Granit.Persistence.Migrations.Wolverine] --> PM PMW --> W[Granit.Wolverine] ``` ## Setup [Section titled “Setup”](#setup) * Base (interceptors only) ```csharp [DependsOn(typeof(GranitPersistenceModule))] public class AppModule : GranitModule { } ``` Registers all four interceptors and `IDataFilter`. * With migrations ```csharp [DependsOn(typeof(GranitPersistenceMigrationsModule))] public class AppModule : GranitModule { public override void ConfigureServices(ServiceConfigurationContext context) { context.Services.AddGranitPersistenceMigrations(context.Builder, options => { options.UseNpgsql(context.Configuration.GetConnectionString("Migrations")); }); } } ``` * With Wolverine migrations ```csharp [DependsOn(typeof(GranitPersistenceMigrationsWolverineModule))] public class AppModule : GranitModule { } ``` Replaces `Channel` dispatcher with durable Wolverine outbox — no batch is lost on failure. ## Isolated DbContext pattern [Section titled “Isolated DbContext pattern”](#isolated-dbcontext-pattern) Every `*.EntityFrameworkCore` package that owns a `DbContext` **must** follow this checklist. No exceptions. ```csharp public class AppointmentDbContext( DbContextOptions options, ICurrentTenant? currentTenant = null, IDataFilter? dataFilter = null) : DbContext(options) { public DbSet Appointments => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppointmentDbContext).Assembly); // MANDATORY — applies all query filters (soft delete, multi-tenant, active, etc.) modelBuilder.ApplyGranitConventions(currentTenant, dataFilter); } } ``` Registration in the module: ```csharp public override void ConfigureServices(ServiceConfigurationContext context) { context.Services.AddGranitDbContext(options => { options.UseNpgsql(context.Configuration.GetConnectionString("Default")); }); } ``` `AddGranitDbContext` handles interceptor wiring automatically — it calls `UseGranitInterceptors(sp)` internally with `ServiceLifetime.Scoped`. Danger Never call `HasQueryFilter` manually in entity configurations. `ApplyGranitConventions` handles all standard filters centrally. Manual filters cause duplicates or conflicts. ## Interceptors [Section titled “Interceptors”](#interceptors) Four interceptors execute on every `SaveChanges` call, in this order: | Order | Interceptor | Behavior | | ----- | ---------------------------------- | ------------------------------------------------------------------------------------------------------- | | 1 | `AuditedEntityInterceptor` | Populates `CreatedAt`, `CreatedBy`, `ModifiedAt`, `ModifiedBy`, auto-generates `Id`, injects `TenantId` | | 2 | `VersioningInterceptor` | Sets `BusinessId` and `Version` on `IVersioned` entities | | 3 | `DomainEventDispatcherInterceptor` | Collects domain events before save, dispatches after commit | | 4 | `SoftDeleteInterceptor` | Converts `DELETE` to `UPDATE` for `ISoftDeletable` entities | ### AuditedEntityInterceptor [Section titled “AuditedEntityInterceptor”](#auditedentityinterceptor) Resolves audit data from DI: * **Who:** `ICurrentUserService.UserId` (falls back to `"system"`) * **When:** `IClock.Now` (never `DateTime.UtcNow`) * **Id:** `IGuidGenerator` (sequential GUIDs for clustered indexes) | Entity state | Action | | ------------ | ------------------------------------------------------------------------------------------------------------------ | | `Added` | Sets `CreatedAt`, `CreatedBy`. Auto-generates `Id` if `Guid.Empty`. Injects `TenantId` if `IMultiTenant` and null. | | `Modified` | Protects `CreatedAt`/`CreatedBy` from overwrite. Sets `ModifiedAt`, `ModifiedBy`. | ### SoftDeleteInterceptor [Section titled “SoftDeleteInterceptor”](#softdeleteinterceptor) Converts physical DELETE to soft delete: ```plaintext DELETE FROM Patients WHERE Id = @id ↓ intercepted ↓ UPDATE Patients SET IsDeleted = true, DeletedAt = @now, DeletedBy = @userId WHERE Id = @id ``` Only applies to entities implementing `ISoftDeletable`. ### DomainEventDispatcherInterceptor [Section titled “DomainEventDispatcherInterceptor”](#domaineventdispatcherinterceptor) Collects and dispatches domain events transactionally: 1. **SavingChanges** — scans change tracker for `IDomainEventSource` entities, collects events, clears event lists 2. **SavedChanges** — dispatches events after commit via `IDomainEventDispatcher` 3. **SaveChangesFailed** — discards events (transaction rolled back) Uses `AsyncLocal>` for thread safety across concurrent `SaveChanges` calls in the same async flow. Default dispatcher is `NullDomainEventDispatcher` (no-op). `Granit.Wolverine` replaces it with real message bus dispatch. ### VersioningInterceptor [Section titled “VersioningInterceptor”](#versioninginterceptor) For `IVersioned` entities on `EntityState.Added`: * Generates `BusinessId` if empty (first version of a new entity) * Determines `Version` from change tracker (starting at 1) * Modified entities are untouched — versioning is explicit (create a new entity with same `BusinessId`) ## Query filters [Section titled “Query filters”](#query-filters) `ApplyGranitConventions` builds a **single combined** `HasQueryFilter` per entity using expression trees. Five filter types, joined with `AND`: | Interface | Filter expression | Default | | ------------------------- | -------------------------------- | ------- | | `ISoftDeletable` | `!e.IsDeleted` | Enabled | | `IActive` | `e.IsActive` | Enabled | | `IProcessingRestrictable` | `!e.IsProcessingRestricted` | Enabled | | `IMultiTenant` | `e.TenantId == currentTenant.Id` | Enabled | | `IPublishable` | `e.IsPublished` | Enabled | Filters are re-evaluated per query via `DataFilter`’s `AsyncLocal` state — EF Core extracts `FilterProxy` boolean properties as parameterized expressions. ### Runtime filter control [Section titled “Runtime filter control”](#runtime-filter-control) Use `IDataFilter` to selectively bypass filters: ```csharp public class PatientAdminService(IDataFilter dataFilter, AppDbContext db) { public async Task> GetAllIncludingDeletedAsync( CancellationToken cancellationToken) { using (dataFilter.Disable()) { return await db.Patients .ToListAsync(cancellationToken) .ConfigureAwait(false); } // Filter re-enabled automatically } } ``` Scopes nest correctly — inner `Enable`/`Disable` calls restore the outer state on disposal. ### Translation conventions [Section titled “Translation conventions”](#translation-conventions) For entities implementing `ITranslatable`, `ApplyGranitConventions` automatically configures: * Foreign key: `Translation.ParentId → Parent.Id` with cascade delete * Unique index: `(ParentId, Culture)` * Culture column: max length 20 (BCP 47) Query extensions for translated entities: ```csharp // Eager load translations for a culture var patients = await db.Patients .IncludeTranslations("fr") .ToListAsync(cancellationToken) .ConfigureAwait(false); // Filter by translation property var results = await db.Patients .WhereTranslation("fr", t => t.DisplayName.Contains("Jean")) .ToListAsync(cancellationToken) .ConfigureAwait(false); ``` ## Data seeding [Section titled “Data seeding”](#data-seeding) Register seed contributors that run at application startup: ```csharp public class CountryDataSeedContributor(AppDbContext db) : IDataSeedContributor { public async Task SeedAsync(DataSeedContext context, CancellationToken cancellationToken) { if (await db.Countries.AnyAsync(cancellationToken).ConfigureAwait(false)) return; db.Countries.AddRange( new Country { Code = "BE", Name = "Belgium" }, new Country { Code = "FR", Name = "France" }); await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } } ``` Register with `services.AddTransient()`. `DataSeedingHostedService` orchestrates all contributors at startup. Errors are logged but do not block application start. ## Soft delete purging [Section titled “Soft delete purging”](#soft-delete-purging) Hard-delete soft-deleted records past retention (ISO 27001): ```csharp await dbContext.PurgeSoftDeletedBeforeAsync( cutoff: DateTimeOffset.UtcNow.AddYears(-3), batchSize: 1000, cancellationToken); ``` Uses `ExecuteDeleteAsync()` with `IgnoreQueryFilters()` — provider-agnostic, bypasses interceptors. ## Multi-tenancy strategies [Section titled “Multi-tenancy strategies”](#multi-tenancy-strategies) `Granit.Persistence` supports three tenant isolation topologies: | Strategy | Isolation | Complexity | Use case | | ------------------- | -------------------------------- | ---------- | --------------------------------- | | `SharedDatabase` | Query filter (`TenantId` column) | Low | Most applications | | `SchemaPerTenant` | PostgreSQL schema per tenant | Medium | Stronger isolation, shared infra | | `DatabasePerTenant` | Separate database per tenant | High | Maximum isolation, regulated data | Configuration: ```json { "TenantIsolation": { "Strategy": "SharedDatabase" }, "TenantSchema": { "NamingConvention": "TenantId", "Prefix": "tenant_" } } ``` Built-in schema activators: `PostgresqlTenantSchemaActivator`, `MySqlTenantSchemaActivator`, `OracleTenantSchemaActivator`. Caution Schema activators must execute on every connection open. Pooled connections retain the previous tenant’s schema — failing to reset is a data isolation breach (ISO 27001). ## Zero-downtime migrations [Section titled “Zero-downtime migrations”](#zero-downtime-migrations) `Granit.Persistence.Migrations` implements the **Expand & Contract** pattern for schema changes that would otherwise require downtime. ### Three phases [Section titled “Three phases”](#three-phases) ``` stateDiagram-v2 [*] --> Expand: Add new column (nullable/default) Expand --> Migrate: Background batch backfill Migrate --> Contract: Remove old column Contract --> [*] ``` | Phase | EF Migration | Application | | ------------ | ----------------------------------- | ----------------------------------- | | **Expand** | `ALTER TABLE ADD COLUMN` (nullable) | Writes to both old and new columns | | **Migrate** | Batch job backfills new column | Reads from new column with fallback | | **Contract** | `ALTER TABLE DROP COLUMN` (old) | Reads/writes new column only | ### Annotating migrations [Section titled “Annotating migrations”](#annotating-migrations) ```csharp [MigrationCycle(MigrationPhase.Expand, "patient-fullname-v2")] public partial class AddPatientFullName : Migration { protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.AddColumn("FullName", "Patients", nullable: true); } } ``` ### Batch processing [Section titled “Batch processing”](#batch-processing) ```csharp registry.Register("patient-fullname-v2", async (context, batch, ct) => { var patients = await context.Set() .Where(p => p.FullName == null) .OrderBy(p => p.Id) .Take(batch.Size) .ToListAsync(ct) .ConfigureAwait(false); foreach (var p in patients) p.FullName = $"{p.FirstName} {p.LastName}"; await context.SaveChangesAsync(ct).ConfigureAwait(false); return new MigrationBatchResult( patients.Count, patients.Count < batch.Size ? null : patients[^1].Id.ToString()); }); ``` Batch delegates must be **idempotent** — re-running over already-migrated rows must have no side effects. ### Configuration [Section titled “Configuration”](#configuration) ```json { "GranitMigrations": { "DefaultBatchSize": 500, "BatchExecutionTimeout": "00:05:00" } } ``` ### Channel vs Wolverine dispatch [Section titled “Channel vs Wolverine dispatch”](#channel-vs-wolverine-dispatch) | | `Granit.Persistence.Migrations` | `+ .Wolverine` | | -------------- | ------------------------------- | ----------------- | | Dispatch | In-memory `Channel` | Durable outbox | | Restart safety | Lost batches on crash | Survives restarts | | Multi-instance | Single node only | Distributed | With Wolverine, the handler returns `object[]` — empty to stop, or `[nextCommand]` to cascade to the next batch via the durable outbox. ## Public API summary [Section titled “Public API summary”](#public-api-summary) | Category | Key types | Package | | ------------------ | ---------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | | Module | `GranitPersistenceModule` | `Granit.Persistence` | | Interceptors | `AuditedEntityInterceptor`, `SoftDeleteInterceptor`, `DomainEventDispatcherInterceptor`, `VersioningInterceptor` | `Granit.Persistence` | | Extensions | `AddGranitPersistence()`, `AddGranitDbContext()`, `UseGranitInterceptors()`, `ApplyGranitConventions()` | `Granit.Persistence` | | Data seeding | `IDataSeeder`, `IDataSeedContributor`, `DataSeedContext` | `Granit.Persistence` | | Purging | `ISoftDeletePurgeTarget`, `PurgeSoftDeletedBeforeAsync()` | `Granit.Persistence` | | Query extensions | `IncludeTranslations()`, `WhereTranslation()`, `OrderByTranslation()` | `Granit.Persistence` | | Multi-tenancy | `TenantIsolationStrategy`, `ITenantSchemaProvider`, `ITenantConnectionStringProvider`, `ITenantSchemaActivator` | `Granit.Persistence` | | Migrations | `MigrationPhase`, `MigrationCycleAttribute`, `IMigrationCycleRegistry`, `BatchMigrationDelegate` | `Granit.Persistence.Migrations` | | Migration dispatch | `IMigrationBatchDispatcher`, `RunMigrationBatchCommand` | `Granit.Persistence.Migrations` | | Wolverine dispatch | `GranitPersistenceMigrationsWolverineModule` | `Granit.Persistence.Migrations.Wolverine` | ## See also [Section titled “See also”](#see-also) * [Core module](./core/) — domain base types, filter interfaces, `IDataFilter` * [Multi-tenancy concept](../../concepts/multi-tenancy.md) — isolation strategies in depth * [Security module](./security/) — `ICurrentUserService` that feeds audit fields * [Wolverine module](./wolverine/) — domain event dispatch and durable messaging * [API Reference](/api/Granit.Persistence.html) (auto-generated from XML docs) # Granit.Privacy > GDPR compliance — data export, erasure, legal agreements, cookie consent management Granit.Privacy implements GDPR data subject rights (Art. 15–18) and cookie consent management. Data export orchestration via saga, right to erasure with cascading notifications, legal agreement tracking, and a strict cookie registry with CMP integration. ## Package structure [Section titled “Package structure”](#package-structure) * Granit.Privacy GDPR data export saga, legal agreements, data provider registry | Package | Role | Depends on | | ---------------------- | --------------------------------------------------------- | ---------------- | | `Granit.Privacy` | GDPR data export/deletion orchestration, legal agreements | `Granit.Core` | | `Granit.Cookies` | Cookie registry, consent enforcement | `Granit.Timing` | | `Granit.Cookies.Klaro` | Klaro CMP consent resolver | `Granit.Cookies` | ## Dependency graph [Section titled “Dependency graph”](#dependency-graph) ``` graph TD PR[Granit.Privacy] --> C[Granit.Core] CK[Granit.Cookies] --> T[Granit.Timing] KL[Granit.Cookies.Klaro] --> CK ``` ## Privacy module [Section titled “Privacy module”](#privacy-module) ### Setup [Section titled “Setup”](#setup) ```csharp [DependsOn(typeof(GranitPrivacyModule))] public class AppModule : GranitModule { public override void ConfigureServices(ServiceConfigurationContext context) { context.Services.AddGranitPrivacy(privacy => { privacy.RegisterDataProvider("PatientModule"); privacy.RegisterDataProvider("BlobStorageModule"); privacy.RegisterDocument( "privacy-policy", "2.1", "Privacy Policy"); privacy.RegisterDocument( "terms-of-service", "1.0", "Terms of Service"); }); } } ``` ### Data provider registry [Section titled “Data provider registry”](#data-provider-registry) Modules register themselves as data providers to participate in GDPR data export and deletion workflows: ```csharp privacy.RegisterDataProvider("PatientModule"); ``` When a data subject requests export or deletion, the saga queries all registered providers and waits for each to complete. ### GDPR data export saga [Section titled “GDPR data export saga”](#gdpr-data-export-saga) ``` stateDiagram-v2 [*] --> Requested: PersonalDataRequestedEvent Requested --> Collecting: Query all providers Collecting --> Prepared: PersonalDataPreparedEvent Prepared --> Completed: ExportCompletedEvent Collecting --> TimedOut: ExportTimedOutEvent Completed --> [*] TimedOut --> [*] ``` The export saga orchestrates data collection across all registered data providers. Each provider prepares its data independently, and the saga assembles the final export package. ### Configuration [Section titled “Configuration”](#configuration) ```json { "Privacy": { "ExportTimeoutMinutes": 5, "ExportMaxSizeMb": 100 } } ``` ### GDPR deletion [Section titled “GDPR deletion”](#gdpr-deletion) ``` sequenceDiagram participant User participant API participant Saga participant Identity participant BlobStorage participant Notifications User->>API: DELETE /privacy/my-data API->>Saga: PersonalDataDeletionRequestedEvent Saga->>Identity: DeleteByIdAsync (hard delete) Saga->>BlobStorage: Delete user blobs Saga->>Notifications: Delete delivery records Identity-->>Saga: IdentityUserDeletedEvent Saga->>User: Acknowledgment notification ``` ### Legal agreements [Section titled “Legal agreements”](#legal-agreements) Track consent to legal documents (privacy policy, terms of service): ```csharp privacy.RegisterDocument("privacy-policy", "2.1", "Privacy Policy"); ``` Provide a store implementation to persist agreement records: ```csharp privacy.UseLegalAgreementStore(); ``` `ILegalAgreementChecker` verifies if a user has signed the current version of a document — useful for middleware that blocks access until consent is renewed after a policy update. ## Cookie consent [Section titled “Cookie consent”](#cookie-consent) ### Strict registry pattern [Section titled “Strict registry pattern”](#strict-registry-pattern) Every cookie must be declared at startup. Writing an undeclared cookie throws `UnregisteredCookieException` (when `ThrowOnUnregistered` is enabled). ```csharp [DependsOn(typeof(GranitCookiesModule))] public class AppModule : GranitModule { public override void ConfigureServices(ServiceConfigurationContext context) { context.Services.AddGranitCookies(cookies => { cookies.RegisterCookie(new CookieDefinition( Name: "session_id", Category: CookieCategory.StrictlyNecessary, RetentionDays: 1, IsHttpOnly: true, Purpose: "Session identification")); cookies.RegisterCookie(new CookieDefinition( Name: "analytics_consent", Category: CookieCategory.Analytics, RetentionDays: 365, IsHttpOnly: false, Purpose: "Analytics tracking preference")); cookies.UseConsentResolver(); }); } } ``` ### Cookie categories (GDPR) [Section titled “Cookie categories (GDPR)”](#cookie-categories-gdpr) | Category | Consent required | Description | | ------------------- | ---------------- | ----------------------------- | | `StrictlyNecessary` | No | Session, CSRF, authentication | | `Functionality` | Yes | Preferences, language | | `Analytics` | Yes | Usage tracking | | `Marketing` | Yes | Advertising, retargeting | | `Other` | Yes | Uncategorized | ### Managed cookie operations [Section titled “Managed cookie operations”](#managed-cookie-operations) Always use `IGranitCookieManager` instead of `IResponseCookies`: ```csharp public class SessionService(IGranitCookieManager cookieManager) { public async Task SetSessionCookieAsync( HttpContext httpContext, string sessionId) { // Checks registry, verifies consent, applies security defaults await cookieManager.SetCookieAsync( httpContext, "session_id", sessionId) .ConfigureAwait(false); } public async Task RevokeAnalyticsAsync(HttpContext httpContext) { // Deletes all cookies in the Analytics category await cookieManager.RevokeCategoryAsync( httpContext, CookieCategory.Analytics) .ConfigureAwait(false); } } ``` **Security defaults applied automatically:** * `Secure = true` (HTTPS only) * `SameSite = Lax` (CSRF protection) * `Expires` calculated via `IClock.Now + RetentionDays` * `HttpOnly` per cookie definition ### IConsentResolver [Section titled “IConsentResolver”](#iconsentresolver) The consent resolver determines if a user has consented to a cookie category: ```csharp public interface IConsentResolver { Task ResolveAsync(HttpContext httpContext, CookieCategory category); } ``` `StrictlyNecessary` cookies always return `true` — no consent check needed. ### Klaro CMP [Section titled “Klaro CMP”](#klaro-cmp) [Klaro](https://klaro.org/) is a self-hosted, EU-sovereign consent management platform. ```csharp [DependsOn(typeof(GranitCookiesKlaroModule))] public class AppModule : GranitModule { } ``` ```json { "Klaro": { "CookieName": "klaro" } } ``` `KlaroConsentResolver` reads the Klaro consent cookie and maps service consent to Granit cookie categories. ### Configuration [Section titled “Configuration”](#configuration-1) ```json { "Cookies": { "ThrowOnUnregistered": true, "DefaultRetentionDays": 365, "ThirdPartyServices": [ { "Name": "Google Analytics", "Category": "Analytics", "CookiePatterns": ["_ga*", "_gid"] } ] } } ``` | Property | Default | Description | | ---------------------- | ------- | --------------------------------------- | | `ThrowOnUnregistered` | `true` | Throw when writing an undeclared cookie | | `DefaultRetentionDays` | `365` | Default cookie lifetime | | `ThirdPartyServices` | `[]` | Third-party service declarations | Caution Setting `ThrowOnUnregistered: false` in production weakens GDPR compliance. Undeclared cookies bypass consent checks and category enforcement. ## Public API summary [Section titled “Public API summary”](#public-api-summary) | Category | Key types | Package | | -------------- | --------------------------------------------------------------------------------------------------------------------------- | ---------------------- | | Module | `GranitPrivacyModule`, `GranitCookiesModule`, `GranitCookiesKlaroModule` | — | | Privacy | `IDataProviderRegistry`, `ILegalDocumentRegistry`, `ILegalAgreementChecker`, `GranitPrivacyBuilder`, `GranitPrivacyOptions` | `Granit.Privacy` | | Privacy events | `PersonalDataRequestedEvent`, `PersonalDataDeletionRequestedEvent`, `ExportCompletedEvent`, `ExportTimedOutEvent` | `Granit.Privacy` | | Cookies | `ICookieRegistry`, `IGranitCookieManager`, `IConsentResolver`, `CookieDefinition`, `CookieCategory`, `GranitCookiesBuilder` | `Granit.Cookies` | | Klaro | `KlaroConsentResolver`, `KlaroOptions` | `Granit.Cookies.Klaro` | | Extensions | `AddGranitPrivacy()`, `AddGranitCookies()`, `AddGranitCookiesKlaro()` | — | ## See also [Section titled “See also”](#see-also) * [Security module](./security/) — Authentication, authorization * [Identity module](./identity/) — GDPR erasure and pseudonymization on user cache * [Core module](./core/) — `ISoftDeletable`, data filter interfaces * [API Reference](/api/Granit.Privacy.html) (auto-generated from XML docs) # Granit.Security > Authentication, authorization, claims transformation, RBAC permissions, back-channel logout Granit.Security provides the authentication and authorization stack for the framework. JWT Bearer validation, claims transformation for Keycloak, Entra ID, and AWS Cognito, dynamic RBAC permissions with per-role caching, back-channel logout — all wired through the module system. ## Package structure [Section titled “Package structure”](#package-structure) | Package | Role | Depends on | | ------------------------------------------ | ------------------------------------------ | -------------------------------------------- | | `Granit.Security` | `ICurrentUserService`, `ActorKind` | `Granit.Core` | | `Granit.Authentication.JwtBearer` | JWT Bearer middleware, back-channel logout | `Granit.Security` | | `Granit.Authentication.Keycloak` | Keycloak claims transformation | `Granit.Authentication.JwtBearer` | | `Granit.Authentication.EntraId` | Entra ID roles parsing | `Granit.Authentication.JwtBearer` | | `Granit.Authentication.Cognito` | Cognito groups → roles | `Granit.Authentication.JwtBearer` | | `Granit.Authorization` | RBAC permissions, dynamic policy provider | `Granit.Security`, `Granit.Caching` | | `Granit.Authorization.EntityFrameworkCore` | EF Core permission grant store | `Granit.Authorization`, `Granit.Persistence` | ## Dependency graph [Section titled “Dependency graph”](#dependency-graph) ``` graph TD S[Granit.Security] --> C[Granit.Core] JWT[Granit.Authentication.JwtBearer] --> S KC[Granit.Authentication.Keycloak] --> JWT EID[Granit.Authentication.EntraId] --> JWT CG[Granit.Authentication.Cognito] --> JWT AZ[Granit.Authorization] --> S AZ --> CA[Granit.Caching] AZEF[Granit.Authorization.EntityFrameworkCore] --> AZ AZEF --> P[Granit.Persistence] ``` ## Setup [Section titled “Setup”](#setup) * Keycloak (recommended) ```csharp [DependsOn(typeof(GranitAuthenticationKeycloakModule))] [DependsOn(typeof(GranitAuthorizationModule))] public class AppModule : GranitModule { } ``` ```json { "Authentication": { "Authority": "https://keycloak.example.com/realms/my-realm", "Audience": "my-client" }, "Keycloak": { "ClientId": "my-client", "AdminRole": "admin", "RoleClaimsSource": "realm_access" } } ``` * Entra ID ```csharp [DependsOn(typeof(GranitAuthenticationEntraIdModule))] [DependsOn(typeof(GranitAuthorizationModule))] public class AppModule : GranitModule { } ``` ```json { "Authentication": { "Authority": "https://login.microsoftonline.com/{tenant-id}/v2.0", "Audience": "api://{client-id}" } } ``` * AWS Cognito ```csharp [DependsOn(typeof(GranitAuthenticationCognitoModule))] [DependsOn(typeof(GranitAuthorizationModule))] public class AppModule : GranitModule { } ``` ```json { "Authentication": { "Authority": "https://cognito-idp.{region}.amazonaws.com/{userPoolId}", "Audience": "{clientId}" }, "Cognito": { "UserPoolId": "eu-west-1_XXXXXXXXX", "ClientId": "my-client-id", "Region": "eu-west-1" } } ``` * Generic JWT Bearer ```csharp [DependsOn(typeof(GranitJwtBearerModule))] [DependsOn(typeof(GranitAuthorizationModule))] public class AppModule : GranitModule { } ``` ```json { "Authentication": { "Authority": "https://idp.example.com", "Audience": "my-api", "RequireHttpsMetadata": true, "NameClaimType": "sub" } } ``` * With EF Core permissions ```csharp [DependsOn(typeof(GranitAuthenticationKeycloakModule))] [DependsOn(typeof(GranitAuthorizationEntityFrameworkCoreModule))] public class AppModule : GranitModule { public override void ConfigureServices(ServiceConfigurationContext context) { context.Services.AddGranitAuthorizationEntityFrameworkCore(); } } ``` ## ICurrentUserService [Section titled “ICurrentUserService”](#icurrentuserservice) The core abstraction for accessing the authenticated actor. Injected everywhere audit fields, tenant resolution, or authorization checks need to know “who is calling.” ```csharp public interface ICurrentUserService { string? UserId { get; } string? UserName { get; } string? Email { get; } string? FirstName { get; } string? LastName { get; } bool IsAuthenticated { get; } ActorKind ActorKind { get; } bool IsMachine { get; } string? ApiKeyId { get; } IReadOnlyList GetRoles(); bool IsInRole(string role); } ``` `ActorKind` distinguishes between `User` (human), `ExternalSystem` (API key / service account), and `System` (background jobs, scheduled tasks). ## Authentication [Section titled “Authentication”](#authentication) ### JWT Bearer [Section titled “JWT Bearer”](#jwt-bearer) `GranitJwtBearerModule` registers: * ASP.NET Core JWT Bearer authentication * `CurrentUserService` — `ICurrentUserService` implementation extracting claims from `HttpContext` * `IRevokedSessionStore` — distributed cache-backed session revocation #### Configuration [Section titled “Configuration”](#configuration) ```json { "Authentication": { "Authority": "https://idp.example.com/realms/my-realm", "Audience": "my-client", "RequireHttpsMetadata": true, "NameClaimType": "sub", "BackChannelLogout": { "Enabled": true, "EndpointPath": "/auth/back-channel-logout", "SessionRevocationTtl": "01:00:00" } } } ``` | Property | Default | Description | | ---------------------------------------- | ----------------------------- | ---------------------------------------- | | `Authority` | — | OIDC issuer URL (required) | | `Audience` | — | Expected `aud` claim (required) | | `RequireHttpsMetadata` | `true` | Enforce HTTPS for metadata endpoint | | `NameClaimType` | `"sub"` | Claim used as user identifier | | `BackChannelLogout.Enabled` | `false` | Enable OIDC back-channel logout | | `BackChannelLogout.EndpointPath` | `"/auth/back-channel-logout"` | Endpoint path | | `BackChannelLogout.SessionRevocationTtl` | `"01:00:00"` | How long revoked sessions are remembered | ### Back-channel logout [Section titled “Back-channel logout”](#back-channel-logout) Provider-agnostic implementation of the [OIDC Back-Channel Logout](https://openid.net/specs/openid-connect-backchannel-1_0.html) specification. When the IdP sends a logout token, the session is revoked in distributed cache. ```csharp // In OnApplicationInitialization app.MapBackChannelLogout(); // POST /auth/back-channel-logout (anonymous) ``` The endpoint validates the logout token signature against the IdP’s JWKS, extracts the `sid` claim, and stores it in `IDistributedCache` with key `granit:revoked-session:{sid}`. Subsequent requests with a revoked `sid` are rejected by the JWT Bearer events handler. Caution The back-channel logout endpoint must be **anonymous** (no auth required) — the IdP calls it directly. It is excluded from OpenAPI documentation by default. ### Keycloak claims transformation [Section titled “Keycloak claims transformation”](#keycloak-claims-transformation) `GranitAuthenticationKeycloakModule` post-configures JWT Bearer with Keycloak-specific behavior: * Extracts roles from `realm_access.roles` or `resource_access.{clientId}.roles` * Maps them to standard `ClaimTypes.Role` claims * Registers an `"Admin"` authorization policy ```csharp // Keycloak JWT payload (simplified) { "realm_access": { "roles": ["admin", "doctor"] }, "resource_access": { "my-client": { "roles": ["manage-patients"] } } } // After transformation → ClaimTypes.Role: "admin", "doctor", "manage-patients" ``` ### Entra ID claims transformation [Section titled “Entra ID claims transformation”](#entra-id-claims-transformation) `GranitAuthenticationEntraIdModule` post-configures JWT Bearer with Entra ID-specific behavior: * Extracts roles from the v1.0 `roles` claim and the v2.0 `wids` claim * Maps them to standard `ClaimTypes.Role` claims ### Cognito claims transformation [Section titled “Cognito claims transformation”](#cognito-claims-transformation) `GranitAuthenticationCognitoModule` post-configures JWT Bearer with Cognito-specific behavior: * Extracts groups from the `cognito:groups` claim (multiple claims with same type) * Maps them to standard `ClaimTypes.Role` claims ```csharp // Cognito JWT payload — groups appear as repeated claims // "cognito:groups": "admin" // "cognito:groups": "doctors" // After transformation → ClaimTypes.Role: "admin", "doctors" ``` ## Authorization [Section titled “Authorization”](#authorization) ### Permission definitions [Section titled “Permission definitions”](#permission-definitions) Modules declare their permissions via `IPermissionDefinitionProvider`: ```csharp public class InvoicePermissionDefinitionProvider : IPermissionDefinitionProvider { public void DefinePermissions(IPermissionDefinitionContext context) { var group = context.AddGroup("Invoices", "Invoice management"); group.AddPermission("Invoices.Invoices.Read", "View invoices"); group.AddPermission("Invoices.Invoices.Create", "Create invoices"); group.AddPermission("Invoices.Invoices.Update", "Edit invoices"); group.AddPermission("Invoices.Invoices.Delete", "Delete invoices"); } } ``` **Naming convention:** `[Module].[Resource].[Action]` Standard actions: `Read`, `Create`, `Update`, `Delete`, `Manage` (all CRUD), `Execute` (non-CRUD). ### Permission checking pipeline [Section titled “Permission checking pipeline”](#permission-checking-pipeline) ``` flowchart LR A[Request] --> B{AlwaysAllow?} B -->|yes| C[✓ Granted] B -->|no| D{AdminRole?} D -->|yes| C D -->|no| E{Cache hit?} E -->|yes| F{Granted?} E -->|no| G[IPermissionGrantStore] G --> H[Cache result] H --> F F -->|yes| C F -->|no| I[✗ Denied] ``` The `PermissionChecker` evaluates in order: 1. `AlwaysAllow` option (development only) 2. Admin role bypass — users with any role in `AdminRoles` get all permissions 3. Per-role cache lookup (key: `perm:{tenantId}:{roleName}:{permissionName}`) 4. `IPermissionGrantStore` fallback (default: `NullPermissionGrantStore` → always denied) ### Using permissions [Section titled “Using permissions”](#using-permissions) ```csharp // Option 1: Attribute on endpoint app.MapGet("/invoices", [Permission("Invoices.Invoices.Read")] async ( AppDbContext db, CancellationToken cancellationToken) => { return await db.Invoices .ToListAsync(cancellationToken) .ConfigureAwait(false); }); // Option 2: Imperative check public class InvoiceService(IPermissionChecker permissionChecker) { public async Task DeleteAsync(Guid id, CancellationToken cancellationToken) { if (!await permissionChecker.IsGrantedAsync( "Invoices.Invoices.Delete", cancellationToken) .ConfigureAwait(false)) { throw new ForbiddenException("Not authorized to delete invoices"); } // ... } } ``` ### Configuration [Section titled “Configuration”](#configuration-1) ```json { "Authorization": { "AdminRoles": ["admin"], "CacheDuration": "00:05:00", "AlwaysAllow": false } } ``` | Property | Default | Description | | --------------- | ----------- | --------------------------------------- | | `AdminRoles` | `["admin"]` | Roles that bypass all permission checks | | `CacheDuration` | `00:05:00` | Per-role permission cache TTL | | `AlwaysAllow` | `false` | Skip all checks (development only) | Danger Never set `AlwaysAllow: true` in production. This disables the entire authorization pipeline. The option validator rejects it outside `Development` environment. ### EF Core permission store [Section titled “EF Core permission store”](#ef-core-permission-store) `Granit.Authorization.EntityFrameworkCore` replaces `NullPermissionGrantStore` with a real store backed by EF Core. **Entity:** ```csharp public class PermissionGrant : AuditedEntity, IMultiTenant { public string Name { get; set; } = string.Empty; // Permission name public string RoleName { get; set; } = string.Empty; // Role granted to public Guid? TenantId { get; set; } // Tenant scope (null = global) } // Unique index: (TenantId, Name, RoleName) ``` **DbContext integration:** ```csharp public class AppDbContext : DbContext, IPermissionGrantDbContext { public DbSet PermissionGrants => Set(); } ``` **Managing grants:** ```csharp public class PermissionAdminService( IPermissionManagerWriter writer, IPermissionManagerReader reader) { public async Task GrantAsync( string roleName, string permissionName, CancellationToken cancellationToken) { await writer.SetAsync(permissionName, roleName, tenantId: null, isGranted: true, cancellationToken) .ConfigureAwait(false); } public async Task> GetGrantedAsync( string roleName, CancellationToken cancellationToken) { return await reader.GetGrantedPermissionsAsync(roleName, tenantId: null, cancellationToken) .ConfigureAwait(false); } } ``` All grant changes are audit-logged via `AuditedEntity` (ISO 27001 — 3-year retention). ## Public API summary [Section titled “Public API summary”](#public-api-summary) | Category | Key types | Package | | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | | Module | `GranitSecurityModule`, `GranitJwtBearerModule`, `GranitAuthenticationKeycloakModule`, `GranitAuthenticationEntraIdModule`, `GranitAuthenticationCognitoModule`, `GranitAuthorizationModule`, `GranitAuthorizationEntityFrameworkCoreModule` | — | | Abstractions | `ICurrentUserService`, `ActorKind` | `Granit.Security` | | Authentication | `CurrentUserService`, `IRevokedSessionStore`, `BackChannelLogoutTokenValidator` | `Granit.Authentication.JwtBearer` | | Claims | `KeycloakClaimsTransformation` | `Granit.Authentication.Keycloak` | | Claims | `EntraIdClaimsTransformation` | `Granit.Authentication.EntraId` | | Claims | `CognitoClaimsTransformation` | `Granit.Authentication.Cognito` | | Authorization | `IPermissionDefinitionProvider`, `IPermissionDefinitionManager`, `IPermissionChecker`, `IPermissionGrantStore`, `PermissionAttribute` | `Granit.Authorization` | | Authorization options | `GranitAuthorizationOptions`, `JwtBearerAuthOptions`, `KeycloakOptions`, `CognitoOptions` | — | | EF Core | `PermissionGrant`, `IPermissionGrantDbContext`, `IPermissionManagerReader`, `IPermissionManagerWriter` | `Granit.Authorization.EntityFrameworkCore` | | Extensions | `AddGranitJwtBearer()`, `AddGranitKeycloak()`, `AddGranitCognito()`, `AddGranitAuthorization()`, `AddGranitAuthorizationEntityFrameworkCore()`, `MapBackChannelLogout()` | — | ## See also [Section titled “See also”](#see-also) * [Identity module](./identity/) — User management, Keycloak Admin API, user cache * [Privacy module](./privacy/) — GDPR compliance, cookies, CORS * [Core module](./core/) — `ICurrentTenant`, exception hierarchy * [Persistence module](./persistence/) — `AuditedEntity` base class, interceptors * [API Reference](/api/Granit.Security.html) (auto-generated from XML docs) # Settings, Features & Reference Data > Application settings (user/tenant/global cascade), feature flags (toggle/numeric/selection), i18n reference data Three complementary modules for runtime application configuration: **Settings** for user/tenant/global key-value pairs with cascading resolution, **Features** for SaaS feature flags with plan-based activation, and **Reference Data** for i18n lookup tables (countries, currencies, statuses). ## Package structure [Section titled “Package structure”](#package-structure) | Package | Role | Depends on | | -------------------------------------------------------- | -------------------------------------------------------------- | -------------------------------------------------------- | | `Granit.Settings` | `ISettingProvider`, `ISettingManager`, cascading resolution | `Granit.Caching`, `Granit.Encryption`, `Granit.Security` | | `Granit.Settings.EntityFrameworkCore` | `SettingsDbContext` (isolated, multi-tenant + soft-delete) | `Granit.Settings`, `Granit.Persistence` | | `Granit.Settings.Endpoints` | User/Global/Tenant HTTP endpoints, `SettingsCultureMiddleware` | `Granit.Settings`, `Granit.Authorization` | | `Granit.Features` | `IFeatureChecker`, `IFeatureLimitGuard`, plan-based cascade | `Granit.Caching`, `Granit.Localization` | | `Granit.Features.EntityFrameworkCore` | `FeaturesDbContext` (isolated) | `Granit.Features`, `Granit.Persistence` | | `Granit.ReferenceData` | `IReferenceDataStoreReader/Writer`, `ReferenceDataEntity` | `Granit.Core` | | `Granit.ReferenceData.EntityFrameworkCore` + `Endpoints` | EF Core store, seeding, generic CRUD endpoints | `Granit.ReferenceData`, `Granit.Persistence` | ## Dependency graph [Section titled “Dependency graph”](#dependency-graph) ``` graph TD S[Granit.Settings] --> CA[Granit.Caching] S --> EN[Granit.Encryption] S --> SE[Granit.Security] SEF[Granit.Settings.EntityFrameworkCore] --> S SEF --> P[Granit.Persistence] SEP[Granit.Settings.Endpoints] --> S SEP --> A[Granit.Authorization] F[Granit.Features] --> CA F --> L[Granit.Localization] FEF[Granit.Features.EntityFrameworkCore] --> F FEF --> P RD[Granit.ReferenceData] --> CO[Granit.Core] RDEF[Granit.ReferenceData.EntityFrameworkCore] --> RD RDEF --> P RDEP[Granit.ReferenceData.Endpoints] --> RD ``` *** ## Settings [Section titled “Settings”](#settings) ### Setup [Section titled “Setup”](#setup) * Core + EF Core ```csharp [DependsOn( typeof(GranitSettingsModule), typeof(GranitSettingsEntityFrameworkCoreModule))] public class AppModule : GranitModule { } ``` Program.cs ```csharp builder.AddGranitSettingsEntityFrameworkCore(opt => opt.UseNpgsql(connectionString)); ``` * With endpoints ```csharp [DependsOn( typeof(GranitSettingsModule), typeof(GranitSettingsEntityFrameworkCoreModule), typeof(GranitSettingsEndpointsModule))] public class AppModule : GranitModule { } ``` ```csharp // Program.cs — map endpoints app.MapGranitUserSettings(); // GET/PUT/DELETE /settings/user/{name} app.MapGranitGlobalSettings(); // GET/PUT /settings/global/{name} app.MapGranitTenantSettings(); // GET/PUT /settings/tenant/{name} ``` ### Defining settings [Section titled “Defining settings”](#defining-settings) Implement `ISettingDefinitionProvider` in any module. Providers are auto-discovered at startup. ```csharp public sealed class AcmeSettingDefinitionProvider : ISettingDefinitionProvider { public void Define(ISettingDefinitionContext context) { context.Add(new SettingDefinition("Acme.DefaultPageSize") { DefaultValue = "25", IsVisibleToClients = true, IsInherited = true, Description = "Default number of items per page" }); context.Add(new SettingDefinition("Acme.SmtpPassword") { DefaultValue = null, IsEncrypted = true, IsVisibleToClients = false, Providers = { "G" } // Global scope only }); } } ``` #### SettingDefinition properties [Section titled “SettingDefinition properties”](#settingdefinition-properties) | Property | Default | Description | | -------------------- | ------------ | ------------------------------------------------------- | | `Name` | *(required)* | Unique setting key | | `DefaultValue` | `null` | Fallback when no provider supplies a value | | `IsEncrypted` | `false` | Encrypt at rest via `IStringEncryptionService` | | `IsVisibleToClients` | `false` | Expose via user-scoped API endpoints | | `IsInherited` | `true` | Lower-priority scopes inherit from higher-priority ones | | `Providers` | `[]` (all) | Allow-list of provider names (`"U"`, `"T"`, `"G"`) | ### Value cascade [Section titled “Value cascade”](#value-cascade) Settings are resolved through a provider chain. The first provider that returns a non-null value wins: ```plaintext User (U) → Tenant (T) → Global (G) → Configuration (appsettings.json) → Default (code) ``` When `IsInherited = false`, each scope is independent and does not fall through. ``` graph LR U[User] -->|null?| T[Tenant] T -->|null?| G[Global] G -->|null?| C[Configuration] C -->|null?| D[Default] style U fill:#4CAF50,color:white style D fill:#9E9E9E,color:white ``` ### Reading settings [Section titled “Reading settings”](#reading-settings) ```csharp public class ReportService(ISettingProvider settings) { public async Task GetPageSizeAsync(CancellationToken ct) { string? value = await settings .GetOrNullAsync("Acme.DefaultPageSize", ct) .ConfigureAwait(false); return int.TryParse(value, out int size) ? size : 25; } } ``` ### Writing settings [Section titled “Writing settings”](#writing-settings) ```csharp public class AdminService(ISettingManager settingManager) { public async Task SetGlobalPageSizeAsync(int size, CancellationToken ct) { await settingManager .SetGlobalAsync("Acme.DefaultPageSize", size.ToString(), ct) .ConfigureAwait(false); } public async Task SetTenantThemeAsync(Guid tenantId, string theme, CancellationToken ct) { await settingManager .SetForTenantAsync(tenantId, "Acme.Theme", theme, ct) .ConfigureAwait(false); } public async Task SetUserLocaleAsync(string userId, string locale, CancellationToken ct) { await settingManager .SetForUserAsync(userId, "Granit.PreferredCulture", locale, ct) .ConfigureAwait(false); } } ``` ### Endpoints [Section titled “Endpoints”](#endpoints) | Scope | Method | Route | Permission | | ------ | -------- | ------------------------- | ------------------------ | | User | `GET` | `/settings/user` | Authenticated | | User | `GET` | `/settings/user/{name}` | Authenticated | | User | `PUT` | `/settings/user/{name}` | Authenticated | | User | `DELETE` | `/settings/user/{name}` | Authenticated | | Global | `GET` | `/settings/global` | `Settings.Global.Read` | | Global | `PUT` | `/settings/global/{name}` | `Settings.Global.Manage` | | Tenant | `GET` | `/settings/tenant` | `Settings.Tenant.Read` | | Tenant | `PUT` | `/settings/tenant/{name}` | `Settings.Tenant.Manage` | ### SettingsCultureMiddleware [Section titled “SettingsCultureMiddleware”](#settingsculturemiddleware) Hydrates `CultureInfo.CurrentUICulture` and `ICurrentTimezoneProvider` from the authenticated user’s `Granit.PreferredCulture` and `Granit.PreferredTimezone` settings. Runs after authentication, before endpoint handlers. No-op for anonymous requests. Program.cs ```csharp app.UseAuthentication(); app.UseMiddleware(); app.UseAuthorization(); ``` ### Configuration [Section titled “Configuration”](#configuration) ```json { "Settings": { "CacheExpiration": "00:30:00" } } ``` | Property | Default | Description | | ----------------- | ---------- | ------------------------------------------- | | `CacheExpiration` | `00:30:00` | Cache entry TTL for resolved setting values | *** ## Features [Section titled “Features”](#features) ### Setup [Section titled “Setup”](#setup-1) * Core only ```csharp [DependsOn(typeof(GranitFeaturesModule))] public class AppModule : GranitModule { } ``` Uses `InMemoryFeatureStore` by default (no persistence). * With EF Core ```csharp [DependsOn( typeof(GranitFeaturesModule), typeof(GranitFeaturesEntityFrameworkCoreModule))] public class AppModule : GranitModule { } ``` ```csharp builder.AddGranitFeaturesEntityFrameworkCore(opt => opt.UseNpgsql(connectionString)); ``` ### Defining features [Section titled “Defining features”](#defining-features) Implement `IFeatureDefinitionProvider` to declare features. Features are grouped for admin UI organization. ```csharp public sealed class AcmeFeatureDefinitionProvider : FeatureDefinitionProvider { public override void Define(IFeatureDefinitionContext context) { FeatureGroupDefinition group = context.AddGroup("Acme", "Acme Application"); group.AddToggle("Acme.VideoConsultation", defaultValue: "false", displayName: "Video Consultation"); group.AddNumeric("Acme.MaxUsersCount", defaultValue: "50", displayName: "Maximum Users", numericConstraint: new NumericConstraint(Min: 1, Max: 10_000)); group.AddSelection("Acme.StorageTier", defaultValue: "standard", displayName: "Storage Tier", selectionValues: new SelectionValues("standard", "premium", "enterprise")); } } ``` #### FeatureDefinition properties [Section titled “FeatureDefinition properties”](#featuredefinition-properties) | Property | Description | | ------------------- | ------------------------------------------------------ | | `Name` | Unique feature name (convention: `Module.FeatureName`) | | `DefaultValue` | Fallback string value | | `ValueType` | `Toggle`, `Numeric`, or `Selection` | | `NumericConstraint` | Min/Max bounds for `Numeric` features | | `SelectionValues` | Allowed values for `Selection` features | ### Value cascade [Section titled “Value cascade”](#value-cascade-1) Features are resolved through a Tenant → Plan → Default cascade: ```plaintext Tenant override → Plan value → Default (code) ``` The application must implement `IPlanIdProvider` and `IPlanFeatureStore` to activate plan-level resolution. Without these, features fall back to their default values. ### IFeatureChecker [Section titled “IFeatureChecker”](#ifeaturechecker) The main abstraction for querying feature state at runtime: ```csharp public class ConsultationService(IFeatureChecker features) { public async Task StartAsync(CancellationToken ct) { // Gate: throws FeatureNotEnabledException (HTTP 403) if disabled await features .RequireEnabledAsync("Acme.VideoConsultation", ct) .ConfigureAwait(false); // Conditional logic bool isEnabled = await features .IsEnabledAsync("Acme.VideoConsultation", ct) .ConfigureAwait(false); // Numeric value long maxUsers = await features .GetNumericAsync("Acme.MaxUsersCount", ct) .ConfigureAwait(false); } } ``` ### IFeatureLimitGuard [Section titled “IFeatureLimitGuard”](#ifeaturelimitguard) Enforces numeric feature limits before mutating operations. Throws `FeatureLimitExceededException` (HTTP 403) when the limit is reached. ```csharp public sealed class CreatePatientHandler( IFeatureLimitGuard limitGuard, IPatientRepository patients) { public async Task HandleAsync(CreatePatientCommand cmd, CancellationToken ct) { long current = await patients.CountAsync(ct).ConfigureAwait(false); await limitGuard .CheckAsync("Acme.MaxUsersCount", current, ct) .ConfigureAwait(false); // ... proceed with creation } } ``` ### Feature gating [Section titled “Feature gating”](#feature-gating) * Minimal API ```csharp app.MapPost("/consultations", handler) .RequiresFeature("Acme.VideoConsultation"); ``` * Controllers ```csharp [RequiresFeature("Acme.VideoConsultation")] public IActionResult StartConsultation() => Ok(); ``` * Wolverine ```csharp // Decorate the message class [RequiresFeature("Acme.ExportPdf")] public sealed class GenerateExportCommand { } ``` Register `RequiresFeatureMiddleware` in your Wolverine setup. When the feature is disabled, `FeatureNotEnabledException` is thrown and mapped to HTTP 403 with `errorCode: "Features:NotEnabled"`. ### Feature store abstractions [Section titled “Feature store abstractions”](#feature-store-abstractions) ```csharp public interface IFeatureStoreReader { Task GetOrNullAsync( string featureName, string? tenantId, CancellationToken cancellationToken = default); } public interface IFeatureStoreWriter { Task SetAsync( string featureName, string? tenantId, string value, CancellationToken cancellationToken = default); Task DeleteAsync( string featureName, string? tenantId, CancellationToken cancellationToken = default); } ``` *** ## Reference Data [Section titled “Reference Data”](#reference-data) ### Overview [Section titled “Overview”](#overview) `ReferenceDataEntity` is an abstract base class for i18n lookup tables (countries, currencies, document types). Each entity has a unique `Code`, 14 language labels, and automatic label resolution based on `CurrentUICulture`. ### Entity structure [Section titled “Entity structure”](#entity-structure) ```csharp public abstract class ReferenceDataEntity : AuditedEntity, IActive { public string Code { get; set; } // 14 language labels public string LabelEn { get; set; } public string LabelFr { get; set; } public string LabelNl { get; set; } public string LabelDe { get; set; } public string LabelEs { get; set; } public string LabelIt { get; set; } public string LabelPt { get; set; } public string LabelZh { get; set; } public string LabelJa { get; set; } public string LabelPl { get; set; } public string LabelTr { get; set; } public string LabelKo { get; set; } public string LabelSv { get; set; } public string LabelCs { get; set; } // Resolved at runtime via CurrentUICulture (not mapped to DB) public virtual string Label { get; } public bool IsActive { get; set; } public int SortOrder { get; set; } public DateTimeOffset? ValidFrom { get; set; } public DateTimeOffset? ValidTo { get; set; } } ``` The `Label` property resolves the appropriate label based on `CultureInfo.CurrentUICulture.TwoLetterISOLanguageName`, falling back to `LabelEn` when the translation is empty. Regional variants use the base language fallback (fr-CA uses `LabelFr`, en-GB uses `LabelEn`, pt-BR uses `LabelPt`). ### Defining a reference data entity [Section titled “Defining a reference data entity”](#defining-a-reference-data-entity) ```csharp public sealed class Country : ReferenceDataEntity { } ``` ### Store abstractions [Section titled “Store abstractions”](#store-abstractions) ```csharp public interface IReferenceDataStoreReader where TEntity : ReferenceDataEntity { Task> GetAllAsync( ReferenceDataQuery? query = null, CancellationToken cancellationToken = default); Task GetByCodeAsync( string code, CancellationToken cancellationToken = default); } public interface IReferenceDataStoreWriter where TEntity : ReferenceDataEntity { Task CreateAsync(TEntity entity, CancellationToken cancellationToken = default); Task UpdateAsync(TEntity entity, CancellationToken cancellationToken = default); Task SetActiveAsync(string code, bool isActive, CancellationToken cancellationToken = default); } ``` ### Registration [Section titled “Registration”](#registration) Reference data stores use **manual registration** per entity type, binding to the host application’s `DbContext`: ```csharp // Program.cs or module ConfigureServices services.AddReferenceDataStore(); services.AddReferenceDataStore(); ``` This registers `IReferenceDataStoreReader`, `IReferenceDataStoreWriter`, and a `IDataSeedContributor` bridge for seeding. ### Mapping endpoints [Section titled “Mapping endpoints”](#mapping-endpoints) Program.cs ```csharp app.MapReferenceDataEndpoints(); app.MapReferenceDataEndpoints(opts => opts.AdminPolicyName = "Custom.Policy"); ``` The entity type name is converted to a kebab-case route segment: | Method | Route | Access | | -------- | -------------------------------- | ------------- | | `GET` | `/reference-data/country` | Authenticated | | `GET` | `/reference-data/country/{code}` | Authenticated | | `POST` | `/reference-data/country` | Admin policy | | `PUT` | `/reference-data/country/{code}` | Admin policy | | `DELETE` | `/reference-data/country/{code}` | Admin policy | ### Seeding reference data [Section titled “Seeding reference data”](#seeding-reference-data) Implement `IReferenceDataSeeder` and register it. The `IDataSeedContributor` bridge invokes seeders during application startup. ```csharp public sealed class CountrySeeder : IReferenceDataSeeder { public IEnumerable GetSeedData() { yield return new Country { Code = "BE", LabelEn = "Belgium", LabelFr = "Belgique", LabelNl = "Belgie", LabelDe = "Belgien", SortOrder = 1 }; yield return new Country { Code = "FR", LabelEn = "France", LabelFr = "France", LabelNl = "Frankrijk", LabelDe = "Frankreich", SortOrder = 2 }; } } ``` *** ## Public API summary [Section titled “Public API summary”](#public-api-summary) | Category | Key types | Package | | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | | Settings modules | `GranitSettingsModule`, `GranitSettingsEntityFrameworkCoreModule`, `GranitSettingsEndpointsModule` | — | | Settings abstractions | `ISettingProvider` (`GetOrNullAsync`, `GetAllAsync`), `ISettingManager` (`SetGlobalAsync`, `SetForTenantAsync`, `SetForUserAsync`) | `Granit.Settings` | | Settings definitions | `SettingDefinition`, `ISettingDefinitionProvider`, `SettingDefinitionManager` | `Granit.Settings` | | Settings options | `SettingsOptions` (section `"Settings"`, `CacheExpiration`) | `Granit.Settings` | | Settings endpoints | `MapGranitUserSettings()`, `MapGranitGlobalSettings()`, `MapGranitTenantSettings()`, `SettingsCultureMiddleware` | `Granit.Settings.Endpoints` | | Features modules | `GranitFeaturesModule`, `GranitFeaturesEntityFrameworkCoreModule` | — | | Features abstractions | `IFeatureChecker`, `IFeatureLimitGuard`, `IFeatureStoreReader`, `IFeatureStoreWriter` | `Granit.Features` | | Features definitions | `FeatureDefinition`, `FeatureDefinitionProvider`, `FeatureValueType`, `NumericConstraint`, `SelectionValues` | `Granit.Features` | | Features gating | `[RequiresFeature]`, `.RequiresFeature()`, `RequiresFeatureMiddleware` | `Granit.Features` | | Reference data | `ReferenceDataEntity`, `IReferenceDataStoreReader`, `IReferenceDataStoreWriter` | `Granit.ReferenceData` | | Reference data registration | `AddReferenceDataStore()`, `MapReferenceDataEndpoints()` | `Granit.ReferenceData.EntityFrameworkCore`, `Granit.ReferenceData.Endpoints` | ## See also [Section titled “See also”](#see-also) * [Localization module](./localization/) — i18n for UI strings and error messages * [Caching module](./caching/) — used by Settings and Features for value caching * [Persistence module](./persistence/) — isolated DbContext pattern for all EF Core stores * [Security module](./security/) — permission-based access for admin endpoints # Granit.Templating & DocumentGeneration > Template rendering pipeline (Scriban), document generation (HTML→PDF, Excel), template store with lifecycle, workflow integration Granit.Templating provides a multi-stage rendering pipeline for text-based output (email, SMS, push notifications) while Granit.DocumentGeneration extends it with binary document generation (PDF via headless Chromium, Excel via ClosedXML). Templates are strongly typed, support culture fallback, and follow a full lifecycle with ISO 27001 audit trail (Draft, PendingReview, Published, Archived). ## Package structure [Section titled “Package structure”](#package-structure) | Package | Role | Depends on | | --------------------------------------- | -------------------------------------------------------------------- | ------------------------------------------- | | `Granit.Templating` | Core pipeline: rendering, resolution, enrichment, store interfaces | — | | `Granit.Templating.Scriban` | Scriban template engine (sandboxed), `now.*` and `context.*` globals | `Granit.Templating` | | `Granit.Templating.EntityFrameworkCore` | EF Core store, `StoreTemplateResolver` (Priority=100), HybridCache | `Granit.Templating`, `Granit.Persistence` | | `Granit.Templating.Endpoints` | 16 admin Minimal API endpoints, `Templates.Manage` permission | `Granit.Templating`, `Granit.Authorization` | | `Granit.Templating.Workflow` | Workflow bridge: FSM validation, `IWorkflowTransitionRecorder` | `Granit.Templating`, `Granit.Workflow` | | `Granit.DocumentGeneration` | `IDocumentGenerator` facade, `IDocumentRenderer` abstraction | `Granit.Templating` | | `Granit.DocumentGeneration.Pdf` | PuppeteerSharp PDF renderer, `IPdfAConverter` | `Granit.DocumentGeneration` | | `Granit.DocumentGeneration.Excel` | ClosedXML `ITemplateEngine` (direct binary output) | `Granit.Templating` | ## Dependency graph [Section titled “Dependency graph”](#dependency-graph) ``` graph TD T[Granit.Templating] TS[Granit.Templating.Scriban] --> T TEF[Granit.Templating.EntityFrameworkCore] --> T TEF --> P[Granit.Persistence] TE[Granit.Templating.Endpoints] --> T TE --> AUTH[Granit.Authorization] TW[Granit.Templating.Workflow] --> T TW --> W[Granit.Workflow] DG[Granit.DocumentGeneration] --> T DGP[Granit.DocumentGeneration.Pdf] --> DG DGE[Granit.DocumentGeneration.Excel] --> T ``` ## Setup [Section titled “Setup”](#setup) * Full stack (production) ```csharp [DependsOn( typeof(GranitTemplatingScribanModule), typeof(GranitTemplatingEntityFrameworkCoreModule), typeof(GranitTemplatingEndpointsModule), typeof(GranitDocumentGenerationPdfModule), typeof(GranitDocumentGenerationExcelModule))] public class AppModule : GranitModule { public override void ConfigureServices(ServiceConfigurationContext context) { context.Services.AddGranitTemplatingEntityFrameworkCore(options => options.UseNpgsql(context.Configuration.GetConnectionString("Templating"))); } } ``` Map the admin endpoints in `Program.cs`: ```csharp app.MapGranitTemplatingAdmin(options => { options.RoutePrefix = "api/v1/templates"; options.TagName = "Template Administration"; }); ``` * Minimal (embedded templates only) ```csharp [DependsOn(typeof(GranitTemplatingScribanModule))] public class AppModule : GranitModule { public override void ConfigureServices(ServiceConfigurationContext context) { // Register embedded HTML templates from the assembly context.Services.AddEmbeddedTemplates(typeof(AcmeTemplates).Assembly); } } ``` No database, no admin UI. Templates are compiled into the assembly as embedded resources. * With Workflow approval ```csharp [DependsOn( typeof(GranitTemplatingScribanModule), typeof(GranitTemplatingEntityFrameworkCoreModule), typeof(GranitTemplatingWorkflowModule))] public class AppModule : GranitModule { } ``` The Workflow bridge replaces `NullTemplateTransitionHook` with `WorkflowTemplateTransitionHook`, enabling FSM-validated transitions and unified audit trail via `IWorkflowTransitionRecorder`. ## Template rendering pipeline [Section titled “Template rendering pipeline”](#template-rendering-pipeline) The text rendering pipeline executes three stages in sequence: ``` flowchart LR subgraph "1. Enrich" D[TData] --> E1["ITemplateDataEnricher #1"] E1 --> E2["ITemplateDataEnricher #2"] E2 --> ED[Enriched TData] end subgraph "2. Resolve" ED --> R1["StoreTemplateResolver
(Priority=100)"] R1 -->|miss| R2["EmbeddedTemplateResolver
(Priority=-100)"] R1 -->|hit| TD[TemplateDescriptor] R2 --> TD end subgraph "3. Render" TD --> ENG["ITemplateEngine
(Scriban)"] ED --> ENG GC["Global Contexts
now.*, context.*"] --> ENG ENG --> RC[RenderedTextResult] end ``` **Stage 1 — Enrichment.** `ITemplateDataEnricher` instances run in ascending `Order`. Each enricher returns a new immutable copy (record `with` expression) — the original data is never mutated. Use enrichers to inject computed values (QR codes, aggregated totals, remote blob URLs) without coupling that logic to the domain layer. **Stage 2 — Resolution.** `ITemplateResolver` implementations are tried by descending `Priority`. The resolver chain applies culture fallback: first `(Name, "fr-BE")`, then `(Name, null)`. Tenant scoping is the resolver’s responsibility. | Resolver | Priority | Source | | -------------------------- | -------- | --------------------------------------------------- | | `StoreTemplateResolver` | 100 | EF Core database (published revisions, HybridCache) | | `EmbeddedTemplateResolver` | -100 | Assembly embedded resources (code-level fallback) | **Stage 3 — Engine rendering.** `ITemplateEngine` selects itself via `CanRender(descriptor)` based on MIME type. Scriban handles `text/html` and `text/plain`. Global contexts are injected under their `ContextName` namespace. ### Declaring template types [Section titled “Declaring template types”](#declaring-template-types) ```csharp // Text template (email, SMS, push) public static class AcmeTemplates { public static readonly TextTemplateType AppointmentReminder = new AppointmentReminderTemplateType(); private sealed class AppointmentReminderTemplateType : TextTemplateType { public override string Name => "Acme.AppointmentReminder"; } } public sealed record AppointmentReminderData( string PatientName, DateTimeOffset AppointmentDate, string DoctorName, string ClinicAddress); ``` ### Rendering a text template [Section titled “Rendering a text template”](#rendering-a-text-template) ```csharp public sealed class AppointmentReminderService( ITextTemplateRenderer renderer, IEmailSender emailSender) { public async Task SendReminderAsync( Patient patient, Appointment appointment, CancellationToken cancellationToken) { var data = new AppointmentReminderData( PatientName: patient.FullName, AppointmentDate: appointment.ScheduledAt, DoctorName: appointment.Doctor.FullName, ClinicAddress: appointment.Location.Address); RenderedTextResult result = await renderer .RenderAsync(AcmeTemplates.AppointmentReminder, data, cancellationToken) .ConfigureAwait(false); await emailSender .SendAsync(patient.Email, subject: "Appointment reminder", html: result.Html, cancellationToken) .ConfigureAwait(false); } } ``` ### Data enrichment [Section titled “Data enrichment”](#data-enrichment) ```csharp public sealed class PaymentQrCodeEnricher : ITemplateDataEnricher { public int Order => 10; public Task EnrichAsync( InvoiceDocumentData data, CancellationToken cancellationToken = default) { using QRCodeGenerator generator = new(); QRCodeData qrData = generator.CreateQrCode( data.PaymentUrl, QRCodeGenerator.ECCLevel.M); SvgQRCode svg = new(qrData); return Task.FromResult(data with { PaymentQrCodeSvg = svg.GetGraphic(5) }); } } ``` Register enrichers in the DI container: ```csharp services.AddTransient, PaymentQrCodeEnricher>(); ``` ## Global contexts [Section titled “Global contexts”](#global-contexts) Global contexts inject ambient data into every template under a named variable. Two are registered by default when using `Granit.Templating.Scriban`: | Namespace | Variable | Example output | | --------- | ---------------------------- | --------------------------- | | `now` | `{{ now.date }}` | `27/02/2026` | | `now` | `{{ now.datetime }}` | `27/02/2026 14:35` | | `now` | `{{ now.iso }}` | `2026-02-27T14:35:00+00:00` | | `now` | `{{ now.year }}` | `2026` | | `now` | `{{ now.month }}` | `02` | | `now` | `{{ now.time }}` | `14:35` | | `context` | `{{ context.culture }}` | `fr-BE` | | `context` | `{{ context.culture_name }}` | `fran\u00e7ais (Belgique)` | | `context` | `{{ context.tenant_id }}` | `3fa85f64-...` or empty | | `context` | `{{ context.tenant_name }}` | `Acme Corp` or empty | Caution User identity is intentionally **not** exposed in global contexts. Avoid injecting PII into `ITemplateGlobalContext` implementations — pass PII through `TData` instead. ### Custom global context [Section titled “Custom global context”](#custom-global-context) ```csharp public sealed class AcmeBrandingContext : ITemplateGlobalContext { public string ContextName => "brand"; public object Resolve() => new { logo_url = "https://cdn.acme.com/logo.svg", primary_color = "#1A73E8", }; } // Registration services.AddSingleton(); ``` Template usage: `{{ brand.logo_url }}`, `{{ brand.primary_color }}`. ## Scriban sandboxing [Section titled “Scriban sandboxing”](#scriban-sandboxing) The Scriban engine runs in strict sandboxed mode: * **`EnableRelaxedMemberAccess = false`** — no access to .NET internals * **No I/O, reflection, or network access** from templates * **Snake\_case property mapping** — `PatientName` becomes `{{ model.patient_name }}` * **Template caching** — parsed `Template` objects are cached by `RevisionId` (thread-safe) * **CancellationToken propagation** — long-running templates can be cancelled ## Document generation pipeline [Section titled “Document generation pipeline”](#document-generation-pipeline) `IDocumentGenerator` extends the text pipeline with a binary conversion step: ``` flowchart LR subgraph "Text Pipeline" DATA[TData] --> ENRICH[Enrichers] ENRICH --> RESOLVE[Resolver Chain] RESOLVE --> ENGINE[ITemplateEngine] end ENGINE -->|TextRenderedContent| RENDERER["IDocumentRenderer
(PuppeteerSharp)"] ENGINE -->|BinaryRenderedContent| RESULT RENDERER --> RESULT[DocumentResult] ``` Two paths through the pipeline: | Path | Engine output | Next step | Example | | ------------- | ----------------------- | ------------------------------------------- | ----------------------------------------- | | HTML-based | `TextRenderedContent` | `IDocumentRenderer` converts HTML to binary | Invoice PDF (Scriban HTML → Chromium PDF) | | Native binary | `BinaryRenderedContent` | Direct output, no renderer needed | Excel report (ClosedXML XLSX) | ### Declaring a document template type [Section titled “Declaring a document template type”](#declaring-a-document-template-type) ```csharp public sealed class InvoiceTemplateType : DocumentTemplateType { public override string Name => "Billing.Invoice"; public override DocumentFormat DefaultFormat => DocumentFormat.Pdf; } public sealed record InvoiceDocumentData( string InvoiceNumber, DateTimeOffset InvoiceDate, string CustomerName, string CustomerAddress, IReadOnlyList Lines, decimal TotalExclVat, decimal VatAmount, decimal TotalInclVat, string PaymentUrl, string? PaymentQrCodeSvg = null); ``` ### Generating a PDF document [Section titled “Generating a PDF document”](#generating-a-pdf-document) ```csharp public sealed class InvoiceService(IDocumentGenerator generator) { public async Task GenerateInvoicePdfAsync( InvoiceDocumentData data, CancellationToken cancellationToken) { DocumentResult result = await generator .GenerateAsync(new InvoiceTemplateType(), data, cancellationToken: cancellationToken) .ConfigureAwait(false); // result.Content contains PDF bytes // result.Format == DocumentFormat.Pdf return result; } } ``` ### Generating an Excel document [Section titled “Generating an Excel document”](#generating-an-excel-document) Excel templates use Base64-encoded XLSX workbooks stored as template content. Placeholders (`{{model.property}}`) in string cells are replaced with data values. Nested objects use dot notation (`{{model.address.city}}`), arrays use bracket notation (`{{model.lines[0].amount}}`). ```csharp public sealed class MonthlyReportTemplateType : DocumentTemplateType { public override string Name => "Reporting.MonthlyReport"; public override DocumentFormat DefaultFormat => DocumentFormat.Excel; } ``` The `ClosedXmlTemplateEngine` returns `BinaryRenderedContent` directly — no `IDocumentRenderer` is needed for Excel output. ### PDF/A-3b conversion [Section titled “PDF/A-3b conversion”](#pdfa-3b-conversion) For long-term archival and electronic invoicing (Factur-X / ZUGFeRD EN 16931), use `IPdfAConverter` to convert standard PDFs to PDF/A-3b: ```csharp public async Task GenerateArchivalInvoiceAsync( InvoiceDocumentData data, IPdfAConverter pdfAConverter, IDocumentGenerator generator, CancellationToken cancellationToken) { DocumentResult pdf = await generator .GenerateAsync(new InvoiceTemplateType(), data, cancellationToken: cancellationToken) .ConfigureAwait(false); return await pdfAConverter .ConvertToPdfAAsync(pdf, new PdfAConversionOptions(), cancellationToken) .ConfigureAwait(false); } ``` ## Template lifecycle [Section titled “Template lifecycle”](#template-lifecycle) Templates stored in the EF Core store follow a strict lifecycle: ``` stateDiagram-v2 [*] --> Draft : SaveDraftAsync Draft --> PendingReview : Workflow bridge
(optional) PendingReview --> Published : Approve PendingReview --> Draft : Reject Draft --> Published : PublishAsync
(without Workflow) Published --> Archived : New version published
or UnpublishAsync Archived --> [*] : Preserved forever
(ISO 27001) ``` | Status | Description | Deletable? | | --------------- | ------------------------------------------------------------------------------------ | ---------- | | `Draft` | Being edited. Not visible to the rendering pipeline. Only one draft per key. | Yes | | `PendingReview` | Submitted for approval. Only with `Granit.Templating.Workflow`. | No | | `Published` | Active version. Exactly one per `TemplateKey`. Resolved by `StoreTemplateResolver`. | No | | `Archived` | Superseded by a newer publication. Preserved indefinitely for ISO 27001 audit trail. | No | ### Lifecycle operations [Section titled “Lifecycle operations”](#lifecycle-operations) | Operation | Method | Effect | | ------------ | ----------------------------------------------- | ---------------------------------------------- | | Save draft | `IDocumentTemplateStoreWriter.SaveDraftAsync` | Creates or replaces the draft for a key | | Publish | `IDocumentTemplateStoreWriter.PublishAsync` | Promotes draft to Published, archives previous | | Unpublish | `IDocumentTemplateStoreWriter.UnpublishAsync` | Archives the published revision | | Delete draft | `IDocumentTemplateStoreWriter.DeleteDraftAsync` | Physically deletes the draft (drafts only) | ### Workflow bridge [Section titled “Workflow bridge”](#workflow-bridge) Without the `Granit.Templating.Workflow` package, transitions are direct (Draft → Published → Archived) via `NullTemplateTransitionHook`. Installing the Workflow bridge replaces this with `WorkflowTemplateTransitionHook`, which: 1. **Validates transitions** via `IWorkflowManager` FSM 2. **Records transitions** via `IWorkflowTransitionRecorder` for unified ISO 27001 audit trail 3. **Supports approval routing** (Draft → PendingReview → Published) ## Resolver chain [Section titled “Resolver chain”](#resolver-chain) Templates are resolved by trying resolvers in descending priority order: | Priority | Resolver | Source | Package | | -------- | -------------------------- | ------------------------------------------- | --------------------------------------- | | 100 | `StoreTemplateResolver` | EF Core database (published revisions only) | `Granit.Templating.EntityFrameworkCore` | | -100 | `EmbeddedTemplateResolver` | Assembly embedded resources | `Granit.Templating` | **Culture fallback strategy** (per resolver): 1. `(Name, Culture)` — e.g. `("Acme.Invoice", "fr-BE")` 2. `(Name, null)` — culture-neutral fallback **Tenant scoping** is handled inside each resolver. The store-backed resolver looks for a tenant-scoped template first, then falls back to the host-level one. ### Embedded template resource naming [Section titled “Embedded template resource naming”](#embedded-template-resource-naming) | Culture | Resource name | | ------------------ | ------------------------------------------------- | | Specific (`"fr"`) | `{AssemblyName}.Templates.{TemplateName}.fr.html` | | Neutral (fallback) | `{AssemblyName}.Templates.{TemplateName}.html` | Register embedded templates: ```csharp services.AddEmbeddedTemplates(typeof(AcmeTemplates).Assembly); ``` ## Admin endpoints [Section titled “Admin endpoints”](#admin-endpoints) 16 endpoints are registered via `MapGranitTemplatingAdmin()`. All require the `Templates.Manage` permission. ### Template endpoints [Section titled “Template endpoints”](#template-endpoints) | Method | Route | Name | Description | | -------- | ------------------------------ | --------------------------- | --------------------------------------------------------------- | | `GET` | `/` | `ListTemplates` | Paginated list with filters (status, culture, category, search) | | `GET` | `/{name}` | `GetTemplateDetail` | Draft + published detail for a template | | `POST` | `/` | `CreateTemplateDraft` | Create a new template draft | | `PUT` | `/{name}` | `UpdateTemplateDraft` | Update an existing draft | | `DELETE` | `/{name}/draft` | `DeleteTemplateDraft` | Delete draft only (published/archived preserved) | | `POST` | `/{name}/publish` | `PublishTemplate` | Publish the current draft | | `POST` | `/{name}/unpublish` | `UnpublishTemplate` | Archive the published revision | | `GET` | `/{name}/lifecycle` | `GetTemplateLifecycle` | Lifecycle status + available transitions | | `POST` | `/{name}/preview` | `PreviewTemplate` | Render draft with test data, returns HTML | | `GET` | `/{name}/variables` | `GetTemplateVariables` | Available variables for autocompletion | | `GET` | `/{name}/history` | `GetTemplateHistory` | Paginated revision history (summaries) | | `GET` | `/{name}/history/{revisionId}` | `GetTemplateRevisionDetail` | Full detail of a specific revision | ### Category endpoints [Section titled “Category endpoints”](#category-endpoints) | Method | Route | Name | Description | | -------- | ------------------ | ------------------------ | ----------------------------------------------- | | `GET` | `/categories` | `ListTemplateCategories` | All categories ordered by sort order | | `POST` | `/categories` | `CreateTemplateCategory` | Create a new category | | `PUT` | `/categories/{id}` | `UpdateTemplateCategory` | Update a category | | `DELETE` | `/categories/{id}` | `DeleteTemplateCategory` | Delete a category (409 if templates associated) | ## Configuration reference [Section titled “Configuration reference”](#configuration-reference) ### PDF renderer [Section titled “PDF renderer”](#pdf-renderer) Bound from configuration section `DocumentGeneration:Pdf`: ```json { "DocumentGeneration": { "Pdf": { "PaperFormat": "A4", "Landscape": false, "MarginTop": "10mm", "MarginBottom": "10mm", "MarginLeft": "10mm", "MarginRight": "10mm", "HeaderTemplate": null, "FooterTemplate": "
/
", "PrintBackground": true, "ChromiumExecutablePath": null, "MaxConcurrentPages": 4 } } } ``` | Property | Default | Description | | ----------------------------------------- | -------- | --------------------------------------------------------- | | `PaperFormat` | `"A4"` | Paper size (`"A4"`, `"A5"`, `"Letter"`) | | `Landscape` | `false` | Landscape orientation | | `MarginTop` / `Bottom` / `Left` / `Right` | `"10mm"` | Margins in CSS units | | `HeaderTemplate` | `null` | HTML header (supports `pageNumber`, `totalPages` classes) | | `FooterTemplate` | `null` | HTML footer (same classes as header) | | `PrintBackground` | `true` | Print background graphics | | `ChromiumExecutablePath` | `null` | Custom Chromium path (auto-download if null) | | `MaxConcurrentPages` | `4` | Max parallel Chromium tabs (1—32) | ### Endpoints [Section titled “Endpoints”](#endpoints) ```csharp app.MapGranitTemplatingAdmin(options => { options.RoutePrefix = "api/v1/templates"; // default: "templates" options.TagName = "Template Administration"; // default: "Templates" }); ``` ## Scriban template example [Section titled “Scriban template example”](#scriban-template-example) A discharge letter template stored in the EF Core store or as an embedded resource: ```html

Discharge Summary

Date: {{ now.date }}

Dear {{ model.patient_name }},

You were admitted on {{ model.admission_date }} and discharged on {{ model.discharge_date }} from the {{ model.ward_name }} ward.

Diagnosis

{{ model.primary_diagnosis }}

Follow-up Instructions

    {{ for instruction in model.follow_up_instructions }}
  • {{ instruction }}
  • {{ end }}

Culture: {{ context.culture }} | Generated: {{ now.datetime }}

``` ## Public API summary [Section titled “Public API summary”](#public-api-summary) | Category | Key types | Package | | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | | Module | `GranitTemplatingModule`, `GranitTemplatingScribanModule`, `GranitTemplatingEntityFrameworkCoreModule`, `GranitTemplatingEndpointsModule`, `GranitTemplatingWorkflowModule` | — | | Module | `GranitDocumentGenerationModule`, `GranitDocumentGenerationPdfModule`, `GranitDocumentGenerationExcelModule` | — | | Pipeline | `ITextTemplateRenderer`, `ITemplateResolver`, `ITemplateEngine` | `Granit.Templating` | | Pipeline | `TemplateDescriptor`, `RenderedContent`, `TextRenderedContent`, `BinaryRenderedContent`, `RenderedTextResult` | `Granit.Templating` | | Keys | `TemplateType`, `TextTemplateType`, `TemplateKey`, `DocumentFormat` | `Granit.Templating` | | Keys | `DocumentTemplateType` | `Granit.DocumentGeneration` | | Enrichment | `ITemplateDataEnricher` | `Granit.Templating` | | Global context | `ITemplateGlobalContext` | `Granit.Templating` | | Store (read) | `IDocumentTemplateStoreReader`, `ITemplateCategoryStoreReader` | `Granit.Templating` | | Store (write) | `IDocumentTemplateStoreWriter`, `ITemplateCategoryStoreWriter` | `Granit.Templating` | | Lifecycle | `TemplateLifecycleStatus`, `ITemplateTransitionHook`, `TemplateRevision` | `Granit.Templating` | | Document gen | `IDocumentGenerator`, `IDocumentRenderer`, `DocumentResult` | `Granit.DocumentGeneration` | | PDF | `PdfRenderOptions`, `IPdfAConverter`, `PdfAConversionOptions` | `Granit.DocumentGeneration.Pdf` | | Permissions | `TemplatingPermissions.Manage` (`"Templates.Manage"`) | `Granit.Templating.Endpoints` | | Extensions | `AddGranitTemplating()`, `AddGranitTemplatingWithScriban()`, `AddEmbeddedTemplates()` | — | | Extensions | `AddGranitDocumentGeneration()`, `AddGranitDocumentGenerationPdf()`, `AddGranitDocumentGenerationExcel()` | — | | Extensions | `MapGranitTemplatingAdmin()` | `Granit.Templating.Endpoints` | ## See also [Section titled “See also”](#see-also) * [Core module](./core/) — Module system, domain base types * [Persistence module](./persistence/) — `AuditedEntityInterceptor`, `ApplyGranitConventions` * [Wolverine module](./wolverine/) — Durable messaging for async document generation * [Notifications module](./notifications/) — Email, SMS, push channels that consume rendered templates * [Background Jobs module](./background-jobs/) — Schedule document generation as background jobs * [API Reference](/api/Granit.Templating.html) (auto-generated from XML docs) # Timeline > Entity activity timeline (Odoo-style chatter) with comments, internal notes, system logs, threaded replies, file attachments, and follow/notify pattern Granit.Timeline adds a unified activity stream (Odoo-style chatter) to any entity — comments, internal notes, system logs, threaded replies, file attachments, and a follow/notify pattern. ## Package structure [Section titled “Package structure”](#package-structure) | Package | Role | Depends on | | ------------------------------------- | --------------------------------------------------- | ----------------------------------------- | | `Granit.Timeline` | `ITimelined`, `ITimelineReader/Writer`, stream DTOs | `Granit.Timing` | | `Granit.Timeline.EntityFrameworkCore` | Durable persistence for entries + attachments | `Granit.Timeline`, `Granit.Persistence` | | `Granit.Timeline.Endpoints` | REST API, permission policies | `Granit.Timeline`, `Granit.Authorization` | | `Granit.Timeline.Notifications` | Notification-backed follower/notifier bridge | `Granit.Timeline` | ## Dependency graph [Section titled “Dependency graph”](#dependency-graph) ``` graph TD TL[Granit.Timeline] --> T[Granit.Timing] TLEF[Granit.Timeline.EntityFrameworkCore] --> TL TLEF --> P[Granit.Persistence] TLE[Granit.Timeline.Endpoints] --> TL TLE --> A[Granit.Authorization] TLN[Granit.Timeline.Notifications] --> TL ``` *** ## Setup [Section titled “Setup”](#setup) * Development (in-memory) ```csharp [DependsOn(typeof(GranitTimelineModule))] public class AppModule : GranitModule { } ``` In-memory stores for entries and followers. No database required. * Production (EF Core + Endpoints + Notifications) ```csharp [DependsOn( typeof(GranitTimelineEntityFrameworkCoreModule), typeof(GranitTimelineEndpointsModule), typeof(GranitTimelineNotificationsModule))] public class AppModule : GranitModule { } ``` ```csharp builder.AddGranitTimelineEntityFrameworkCore( opts => opts.UseNpgsql(connectionString)); // Map endpoints in the app pipeline app.MapTimelineEndpoints(); ``` ## Marking entities as timelined [Section titled “Marking entities as timelined”](#marking-entities-as-timelined) Add the `ITimelined` marker interface to any entity that should have an activity stream: ```csharp public class Invoice : FullAuditedEntity, ITimelined { public string InvoiceNumber { get; set; } = string.Empty; public decimal Amount { get; set; } public InvoiceStatus Status { get; set; } } ``` The entity type name is derived from the CLR type name by convention (`"Invoice"`), or can be overridden with `[Timelined("custom-name")]`. ## Posting entries [Section titled “Posting entries”](#posting-entries) ```csharp public class InvoiceApprovalHandler(ITimelineWriter timeline) { public async Task HandleAsync( InvoiceApproved evt, CancellationToken cancellationToken) { // System log — immutable, cannot be deleted (ISO 27001) await timeline.PostEntryAsync( "Invoice", evt.InvoiceId.ToString(), TimelineEntryType.SystemLog, $"Invoice approved by **{evt.ApprovedBy}** for amount {evt.Amount:C}.", cancellationToken: cancellationToken).ConfigureAwait(false); } } ``` Entry bodies support Markdown formatting. The `@mention` syntax (`@userId`) triggers automatic follower subscription and mention notifications when the Endpoints module processes the entry. ## TimelineStreamEntry (DTO) [Section titled “TimelineStreamEntry (DTO)”](#timelinestreamentry-dto) ```csharp public sealed record TimelineStreamEntry { public required Guid Id { get; init; } public required DateTimeOffset OccurredAt { get; init; } public required TimelineStreamEntryType EntryType { get; init; } public string? AuthorId { get; init; } public string? AuthorName { get; init; } public string Body { get; init; } = string.Empty; public IReadOnlyList Attachments { get; init; } = []; public Guid? ParentEntryId { get; init; } } ``` | Entry type | Description | Deletable | | -------------- | ------------------------------------------- | ------------------------- | | `Comment` | Human-authored comment visible to all users | Yes (soft-delete, GDPR) | | `InternalNote` | Staff-only internal note | Yes (soft-delete, GDPR) | | `SystemLog` | Auto-generated audit entry | No (immutable, ISO 27001) | ## REST API endpoints [Section titled “REST API endpoints”](#rest-api-endpoints) All endpoints are prefixed with `/api/granit/timeline`. | Method | Path | Description | Permission | | -------- | -------------------------------------------- | ---------------------------------------- | ------------------------- | | `GET` | `/{entityType}/{entityId}` | Paginated activity stream (newest first) | `Timeline.Entries.Read` | | `POST` | `/{entityType}/{entityId}/entries` | Post a comment, note, or system log | `Timeline.Entries.Create` | | `DELETE` | `/{entityType}/{entityId}/entries/{entryId}` | Soft-delete an entry (GDPR) | `Timeline.Entries.Create` | | `POST` | `/{entityType}/{entityId}/follow` | Subscribe current user as follower | `Timeline.Entries.Create` | | `DELETE` | `/{entityType}/{entityId}/follow` | Unsubscribe current user | `Timeline.Entries.Create` | | `GET` | `/{entityType}/{entityId}/followers` | List follower user IDs | `Timeline.Entries.Read` | ## Follow/notify pattern [Section titled “Follow/notify pattern”](#follownotify-pattern) ``` sequenceDiagram participant U as User A participant API as Timeline API participant FS as FollowerService participant N as Notifier U->>API: POST /Invoice/42/entries (body: "@user-b reviewed") API->>API: PostEntryAsync (persist) API->>FS: FollowAsync("user-b") (auto-subscribe @mention) API->>FS: GetFollowerIdsAsync() → ["user-a", "user-b"] API->>N: NotifyEntryPostedAsync (fan-out to followers) API->>N: NotifyMentionedUsersAsync (extra channels for @user-b) ``` Without `Granit.Timeline.Notifications`, the follower service uses an in-memory store and the notifier is a no-op (`NullTimelineNotifier`). Installing the Notifications package replaces both with notification-backed implementations that leverage `Granit.Notifications` for multi-channel delivery (email, push, SignalR). ## Attachments [Section titled “Attachments”](#attachments) Timeline entries support file attachments via `ITimelineWriter.AddAttachmentAsync()`, referencing blob IDs managed by `Granit.BlobStorage`: ```csharp await timeline.AddAttachmentAsync( entryId, blobId: uploadResult.BlobId, fileName: "scan.pdf", contentType: "application/pdf", sizeBytes: 245_000, cancellationToken).ConfigureAwait(false); ``` ## Public API summary [Section titled “Public API summary”](#public-api-summary) | Category | Key types | Package | | ------------- | --------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- | | Modules | `GranitTimelineModule`, `GranitTimelineEntityFrameworkCoreModule`, `GranitTimelineEndpointsModule`, `GranitTimelineNotificationsModule` | Timeline | | Timeline core | `ITimelined`, `ITimelineReader`, `ITimelineWriter`, `ITimelineFollowerService`, `ITimelineNotifier` | `Granit.Timeline` | | DTOs | `TimelineStreamEntry`, `TimelineStreamEntryType`, `TimelineAttachmentInfo`, `PostTimelineEntryRequest` | `Granit.Timeline` | | Permissions | `TimelinePermissions.Entries.Read`, `TimelinePermissions.Entries.Create` | `Granit.Timeline.Endpoints` | | Extensions | `AddGranitTimeline()`, `AddGranitTimelineEntityFrameworkCore()`, `MapTimelineEndpoints()` | — | ## See also [Section titled “See also”](#see-also) * [Notifications module](./notifications/) — Multi-channel notification engine used by Timeline.Notifications * [Persistence module](./persistence/) — EF Core interceptors, query filters * [Blob Storage module](./blob-storage/) — File storage for timeline attachments * [Observability module](./observability/) — Audit logging # Utilities > Validation (FluentValidation + international identifiers), timing (IClock, timezone), and sequential GUID generation Four packages providing cross-cutting utility services. `Granit.Validation` wraps FluentValidation with structured error codes and international validators (IBAN, BIC, E.164, ISO 3166). `Granit.Validation.Europe` adds France/Belgium-specific regulatory validators. `Granit.Timing` provides testable time abstractions with per-request timezone support. `Granit.Guids` generates sequential GUIDs optimized for clustered database indexes. ## Package structure [Section titled “Package structure”](#package-structure) | Package | Role | Depends on | | -------------------------- | --------------------------------------------------------------- | ------------------------------------------------- | | `Granit.Timing` | `IClock`, `ICurrentTimezoneProvider`, `TimeProvider` | `Granit.Core` | | `Granit.Guids` | `IGuidGenerator`, sequential GUIDs for clustered indexes | `Granit.Timing` | | `Granit.Validation` | `GranitValidator`, international validators, endpoint filter | `Granit.ExceptionHandling`, `Granit.Localization` | | `Granit.Validation.Europe` | France/Belgium-specific validators (NISS, NIR, SIREN, VAT) | `Granit.Validation`, `Granit.Localization` | ## Dependency graph [Section titled “Dependency graph”](#dependency-graph) ``` graph TD T[Granit.Timing] --> CO[Granit.Core] G[Granit.Guids] --> T V[Granit.Validation] --> EH[Granit.ExceptionHandling] V --> L[Granit.Localization] VE[Granit.Validation.Europe] --> V VE --> L ``` *** ## Granit.Validation [Section titled “Granit.Validation”](#granitvalidation) FluentValidation integration with structured error codes. All validators inherit from `GranitValidator` which enforces `CascadeMode.Continue` (all errors returned in a single response). Error messages are replaced with structured codes (`Granit:Validation:*`) that the SPA resolves from its localization dictionary. ### Setup [Section titled “Setup”](#setup) ```csharp [DependsOn(typeof(GranitValidationModule))] public class AppModule : GranitModule { } ``` The module auto-discovers all `IValidator` implementations from loaded module assemblies. Manual registration is only needed for modules without the Wolverine handler attribute: ```csharp services.AddGranitValidatorsFromAssemblyContaining(); ``` ### Writing validators [Section titled “Writing validators”](#writing-validators) ```csharp public record CreatePatientRequest( string FirstName, string LastName, string Email, string Phone, string CountryCode, string Iban); public class CreatePatientRequestValidator : GranitValidator { public CreatePatientRequestValidator() { RuleFor(x => x.FirstName).NotEmpty().MaximumLength(100); RuleFor(x => x.LastName).NotEmpty().MaximumLength(100); RuleFor(x => x.Email).Email(); RuleFor(x => x.Phone).E164Phone(); RuleFor(x => x.CountryCode).Iso3166Alpha2CountryCode(); RuleFor(x => x.Iban).Iban(); } } ``` ### Minimal API endpoint filter [Section titled “Minimal API endpoint filter”](#minimal-api-endpoint-filter) Apply validation to Minimal API endpoints with `.ValidateBody()`: ```csharp app.MapPost("/api/v1/patients", CreatePatient) .ValidateBody(); ``` When validation fails, the filter returns `422 Unprocessable Entity` with a `HttpValidationProblemDetails` body: ```json { "status": 422, "errors": { "Email": ["Granit:Validation:InvalidEmail"], "Phone": ["Granit:Validation:InvalidE164Phone"], "Iban": ["Granit:Validation:InvalidIban"] } } ``` ### International validators [Section titled “International validators”](#international-validators) Extension methods on `IRuleBuilder` for common international formats: | Category | Validator | Format | Error code | | -------- | ----------------------------- | --------------------------- | ------------------------------- | | Contact | `.Email()` | RFC 5321 practical subset | `InvalidEmail` | | Contact | `.E164Phone()` | `+` followed by 7-15 digits | `InvalidE164Phone` | | Payment | `.Iban()` | ISO 13616 (MOD-97 check) | `InvalidIban` | | Payment | `.BicSwift()` | ISO 9362 (8 or 11 chars) | `InvalidBicSwift` | | Payment | `.SepaCreditorIdentifier()` | EPC262-08 (MOD 97-10) | `InvalidSepaCreditorIdentifier` | | Locale | `.Iso3166Alpha2CountryCode()` | 2 uppercase letters | `InvalidIso3166Alpha2` | | Locale | `.Bcp47LanguageTag()` | `fr`, `fr-BE`, `zh-Hans-CN` | `InvalidBcp47LanguageTag` | All error codes are prefixed with `Granit:Validation:` (omitted in the table for brevity). ### Custom error codes [Section titled “Custom error codes”](#custom-error-codes) Use `.WithErrorCodeAndMessage()` to set both error code and message to the same value, preventing them from silently diverging: ```csharp RuleFor(x => x.AppointmentDate) .GreaterThan(DateTimeOffset.UtcNow) .WithErrorCodeAndMessage("Appointments:DateMustBeFuture"); ``` *** ## Granit.Validation.Europe [Section titled “Granit.Validation.Europe”](#granitvalidationeurope) EU-specific regulatory validators for France and Belgium. Separate package to avoid pulling regulatory dependencies into applications that do not need them. ### Setup [Section titled “Setup”](#setup-1) ```csharp [DependsOn(typeof(GranitValidationEuropeModule))] public class AppModule : GranitModule { } ``` ### Usage [Section titled “Usage”](#usage) ```csharp public class RegisterDoctorRequestValidator : GranitValidator { public RegisterDoctorRequestValidator() { RuleFor(x => x.Niss).BelgianNiss(); RuleFor(x => x.InamiNumber).BelgianInami(); RuleFor(x => x.Vat).EuropeanVat(); RuleFor(x => x.PostalCode).BelgianPostalCode(); } } public class RegisterClinicRequestValidator : GranitValidator { public RegisterClinicRequestValidator() { RuleFor(x => x.Siret).FrenchSiret(); RuleFor(x => x.Vat).FrenchVat(); RuleFor(x => x.Finess).FrenchFiness(); RuleFor(x => x.PostalCode).FrenchPostalCode(); } } ``` ### Validator reference [Section titled “Validator reference”](#validator-reference) **Personal identifiers:** | Validator | Country | Format | Check algorithm | | ---------------- | ------- | --------------------------------- | ------------------------------- | | `.BelgianNiss()` | BE | 11 digits (NISS/INSZ/SSIN) | MOD 97 (pre-2000 and post-2000) | | `.FrenchNir()` | FR | 15 chars (NIR / Securite Sociale) | MOD 97, Corse support (2A/2B) | | `.BelgianEid()` | BE | 12 digits (eID card number) | MOD 97 check pair | **Company identifiers:** | Validator | Country | Format | Check algorithm | | ------------------ | ------- | ----------------------------- | ----------------------- | | `.FrenchSiren()` | FR | 9 digits | Luhn | | `.FrenchSiret()` | FR | 14 digits (SIREN + NIC) | Luhn over all 14 digits | | `.BelgianBce()` | BE | 10 digits (BCE/KBO) | MOD 97 check pair | | `.FrenchNafCode()` | FR | 4 digits + 1 letter (NAF/APE) | Format only | **Tax identifiers:** | Validator | Country | Format | Check algorithm | | ---------------- | ------- | ---------------------------------- | ----------------------------------------------------------------- | | `.BelgianVat()` | BE | `BE` + 10 digits | BCE algorithm | | `.FrenchVat()` | FR | `FR` + 2-digit key + 9-digit SIREN | Key = `(12 + 3 * (SIREN % 97)) % 97` | | `.EuropeanVat()` | EU | Country prefix + national format | Dispatches per country (algorithmic for FR/BE, format for others) | **Payment identifiers:** | Validator | Country | Format | Check algorithm | | ------------------------- | ------- | ---------------------------------------- | --------------------------------------------- | | `.FrenchRib()` | FR | 23 chars (bank + branch + account + key) | `97 - (89*bank + 15*branch + 3*account) % 97` | | `.BelgianAccountNumber()` | BE | `NNN-NNNNNNN-NN` (legacy pre-IBAN) | `first 10 digits % 97` | **Professional registries (healthcare):** | Validator | Country | Format | Check algorithm | | ----------------- | ------- | ----------------------- | ----------------- | | `.FrenchRpps()` | FR | 11 digits (RPPS) | Luhn | | `.FrenchAdeli()` | FR | 9 digits (ADELI) | Format + length | | `.FrenchFiness()` | FR | 9 digits (FINESS) | Luhn | | `.BelgianInami()` | BE | 11 digits (INAMI/RIZIV) | MOD 97 check pair | **Postal addresses:** | Validator | Country | Format | Notes | | ---------------------- | ------- | ------------------------ | ------------------------------------------------- | | `.FrenchPostalCode()` | FR | 5 digits (01000-99999) | Includes Corse (20xxx) and DOM-TOM (97xxx, 98xxx) | | `.BelgianPostalCode()` | BE | 4 digits (1000-9999) | — | | `.FrenchInseeCode()` | FR | 5 chars (dept + commune) | Supports 2A/2B (Corse), DOM 971-976 | *** ## Granit.Timing [Section titled “Granit.Timing”](#granittiming) Testable time abstraction for the entire framework. Replaces all calls to `DateTime.Now` / `DateTime.UtcNow` with injectable services. Uses `AsyncLocal` for per-request timezone propagation. ### Setup [Section titled “Setup”](#setup-2) ```csharp [DependsOn(typeof(GranitTimingModule))] public class AppModule : GranitModule { } ``` ### IClock [Section titled “IClock”](#iclock) The primary abstraction for accessing the current time and performing timezone conversions: ```csharp public interface IClock { DateTimeOffset Now { get; } bool SupportsMultipleTimezone { get; } DateTimeOffset Normalize(DateTimeOffset dateTime); DateTimeOffset ConvertToUserTime(DateTimeOffset utcDateTime); DateTimeOffset ConvertToUtc(DateTimeOffset dateTime); } ``` **Usage in a service:** ```csharp public class AppointmentService(IClock clock, AppDbContext db) { public async Task IsAvailableAsync( Guid doctorId, DateTimeOffset requestedTime, CancellationToken cancellationToken) { DateTimeOffset utcTime = clock.Normalize(requestedTime); return !await db.Appointments .AnyAsync(a => a.DoctorId == doctorId && a.ScheduledAt <= utcTime && a.EndAt > utcTime, cancellationToken) .ConfigureAwait(false); } public Appointment CreateAppointment(Guid doctorId, DateTimeOffset scheduledAt) { return new Appointment { DoctorId = doctorId, ScheduledAt = clock.Normalize(scheduledAt), CreatedAt = clock.Now, // Always UTC }; } } ``` ### ICurrentTimezoneProvider [Section titled “ICurrentTimezoneProvider”](#icurrenttimezoneprovider) Per-request timezone context backed by `AsyncLocal`. Set it early in the request pipeline (e.g., from a header, claim, or user preference): ```csharp app.Use(async (context, next) => { var timezoneProvider = context.RequestServices.GetRequiredService(); timezoneProvider.Timezone = context.Request.Headers["X-Timezone"].FirstOrDefault() ?? "Europe/Brussels"; await next(context).ConfigureAwait(false); }); ``` Then `IClock.ConvertToUserTime()` converts UTC dates to the user’s local time: ```csharp public class AppointmentResponse { public required DateTimeOffset ScheduledAtUtc { get; init; } public required DateTimeOffset ScheduledAtLocal { get; init; } } // In the handler: var response = new AppointmentResponse { ScheduledAtUtc = appointment.ScheduledAt, ScheduledAtLocal = clock.ConvertToUserTime(appointment.ScheduledAt), }; ``` ### DisableDateTimeNormalizationAttribute [Section titled “DisableDateTimeNormalizationAttribute”](#disabledatetimenormalizationattribute) Opt out of automatic UTC normalization for properties that store user-local times (e.g., birth dates where timezone is irrelevant): ```csharp public class Patient { [DisableDateTimeNormalization] public DateTimeOffset DateOfBirth { get; set; } } ``` ### Testing [Section titled “Testing”](#testing) Replace `TimeProvider` in tests to control time: ```csharp var fakeTime = new FakeTimeProvider(new DateTimeOffset(2026, 3, 15, 10, 0, 0, TimeSpan.Zero)); services.AddSingleton(fakeTime); // IClock.Now will return 2026-03-15T10:00:00Z // Advance: fakeTime.Advance(TimeSpan.FromMinutes(30)); ``` ### Service lifetimes [Section titled “Service lifetimes”](#service-lifetimes) | Service | Lifetime | Rationale | | -------------------------- | --------- | ---------------------------------------------------------------------- | | `TimeProvider` | Singleton | `TimeProvider.System` is stateless and thread-safe | | `ICurrentTimezoneProvider` | Singleton | Backed by `AsyncLocal` — value is per-async-context | | `IClock` | Singleton | Stateless — delegates to `TimeProvider` and `ICurrentTimezoneProvider` | ### Configuration reference [Section titled “Configuration reference”](#configuration-reference) | Property | Default | Description | | ----------------- | ------- | ------------------------------------------------------------------- | | `DefaultTimezone` | `null` | Fallback timezone when none is set per-request (IANA or Windows ID) | *** ## Granit.Guids [Section titled “Granit.Guids”](#granitguids) Sequential GUID generator optimized for clustered database indexes. Random GUIDs fragment B-tree indexes because inserts land at random pages. Sequential GUIDs place the timestamp at the front (or end, for SQL Server) so new rows are always appended to the last page, eliminating page splits. ### Setup [Section titled “Setup”](#setup-3) ```csharp [DependsOn(typeof(GranitGuidsModule))] public class AppModule : GranitModule { } ``` ### Usage [Section titled “Usage”](#usage-1) ```csharp public class PatientService(IGuidGenerator guidGenerator, AppDbContext db) { public Patient Create(string firstName, string lastName) { return new Patient { Id = guidGenerator.Create(), // Sequential GUID FirstName = firstName, LastName = lastName, }; } } ``` ### IGuidGenerator [Section titled “IGuidGenerator”](#iguidgenerator) ```csharp public interface IGuidGenerator { Guid Create(); } ``` Two implementations: | Implementation | Strategy | DI registration | | ------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------- | | `SequentialGuidGenerator` | Timestamp (6 bytes from `IClock`) + 10 cryptographic random bytes | Default (singleton) | | `SimpleGuidGenerator` | Delegates to `Guid.NewGuid()` | Available via `SimpleGuidGenerator.Instance` for non-DI contexts | ### Sequential GUID types [Section titled “Sequential GUID types”](#sequential-guid-types) The byte layout depends on the database engine: | Type | Timestamp position | Database | | -------------------- | -------------------------------------- | --------------------- | | `SequentialAsString` | Front (sorted by `Guid.ToString()`) | **PostgreSQL**, MySQL | | `SequentialAsBinary` | Front (sorted by `Guid.ToByteArray()`) | Oracle | | `SequentialAtEnd` | End (Data4 block) | SQL Server | **Default:** `SequentialAsString` (optimized for PostgreSQL). ``` block-beta columns 16 block:asstring["SequentialAsString (PostgreSQL)"] columns 16 t1["T"] t2["T"] t3["T"] t4["T"] t5["T"] t6["T"] r1["R"] r2["R"] r3["R"] r4["R"] r5["R"] r6["R"] r7["R"] r8["R"] r9["R"] r10["R"] end block:atend["SequentialAtEnd (SQL Server)"] columns 16 s1["R"] s2["R"] s3["R"] s4["R"] s5["R"] s6["R"] s7["R"] s8["R"] s9["R"] s10["R"] s11["T"] s12["T"] s13["T"] s14["T"] s15["T"] s16["T"] end style t1 fill:#4a9eff style t2 fill:#4a9eff style t3 fill:#4a9eff style t4 fill:#4a9eff style t5 fill:#4a9eff style t6 fill:#4a9eff style s11 fill:#4a9eff style s12 fill:#4a9eff style s13 fill:#4a9eff style s14 fill:#4a9eff style s15 fill:#4a9eff style s16 fill:#4a9eff ``` **T** = timestamp bytes (6 bytes, millisecond precision from `IClock`), **R** = cryptographically random bytes (10 bytes). ### Configuration [Section titled “Configuration”](#configuration) ```csharp services.AddGranitGuids(options => { options.DefaultSequentialGuidType = SequentialGuidType.SequentialAtEnd; // SQL Server }); ``` | Property | Default | Description | | --------------------------- | -------------------- | --------------------------- | | `DefaultSequentialGuidType` | `SequentialAsString` | Sequential GUID byte layout | *** ## Public API summary [Section titled “Public API summary”](#public-api-summary) | Category | Key types | Package | | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | | Modules | `GranitValidationModule`, `GranitValidationEuropeModule`, `GranitTimingModule`, `GranitGuidsModule` | — | | Validation | `GranitValidator`, `FluentValidationEndpointFilter`, `.ValidateBody()`, `.WithErrorCodeAndMessage()` | `Granit.Validation` | | International validators | `.Email()`, `.E164Phone()`, `.Iban()`, `.BicSwift()`, `.SepaCreditorIdentifier()`, `.Iso3166Alpha2CountryCode()`, `.Bcp47LanguageTag()` | `Granit.Validation` | | European validators | `.BelgianNiss()`, `.FrenchNir()`, `.BelgianEid()`, `.FrenchSiren()`, `.FrenchSiret()`, `.BelgianBce()`, `.FrenchNafCode()`, `.BelgianVat()`, `.FrenchVat()`, `.EuropeanVat()`, `.FrenchRib()`, `.BelgianAccountNumber()`, `.FrenchRpps()`, `.FrenchAdeli()`, `.FrenchFiness()`, `.BelgianInami()`, `.FrenchPostalCode()`, `.BelgianPostalCode()`, `.FrenchInseeCode()` | `Granit.Validation.Europe` | | Timing | `IClock`, `ICurrentTimezoneProvider`, `CurrentTimezoneProvider`, `Clock`, `ClockOptions`, `DisableDateTimeNormalizationAttribute` | `Granit.Timing` | | GUIDs | `IGuidGenerator`, `SequentialGuidGenerator`, `SimpleGuidGenerator`, `SequentialGuidType`, `GuidGeneratorOptions` | `Granit.Guids` | | Extensions | `AddGranitValidation()`, `AddGranitValidatorsFromAssemblyContaining()`, `AddGranitTiming()`, `AddGranitGuids()` | — | ## See also [Section titled “See also”](#see-also) * [Exception handling](./api-web.mdx#granitexceptionhandling) — `IExceptionStatusCodeMapper` for validation exceptions * [Core module](./core/) — Exception hierarchy, `IHasValidationErrors` * [Security module](./security/) — `ICurrentUserService` (often used alongside `IClock` for audit) * [Persistence module](./persistence/) — `AuditedEntityInterceptor` uses `IClock` for timestamps * [API Reference](/api/Granit.Validation.html) (auto-generated from XML docs) # Granit.Vault & Encryption > HashiCorp Vault, Azure Key Vault, AWS KMS, and Google Cloud KMS transit encryption, dynamic credentials, AES-256 string encryption Granit.Encryption provides a pluggable string encryption service with AES-256-CBC as the default provider. Granit.Vault is the abstraction layer defining `ITransitEncryptionService` and `IDatabaseCredentialProvider`. Provider packages implement those interfaces: Granit.Vault.HashiCorp wraps VaultSharp for HashiCorp Vault Transit encryption and dynamic database credentials, Granit.Vault.Azure uses Azure Key Vault for RSA-OAEP-256 encryption and dynamic credentials via Key Vault Secrets, Granit.Vault.Aws targets AWS KMS and Secrets Manager, and Granit.Vault.GoogleCloud uses Google Cloud KMS for symmetric encryption and Secret Manager for dynamic database credentials. All provider modules are automatically disabled in Development environments so no external vault server is required locally. ## Package structure [Section titled “Package structure”](#package-structure) | Package | Role | Depends on | | -------------------------- | ---------------------------------------------------------------------------------------------- | ------------------- | | `Granit.Encryption` | `IStringEncryptionService`, AES-256-CBC provider | `Granit.Core` | | `Granit.Vault` | Abstraction layer — `ITransitEncryptionService`, `IDatabaseCredentialProvider`, localization | `Granit.Encryption` | | `Granit.Vault.HashiCorp` | HashiCorp Vault provider — VaultSharp client, Transit encryption, dynamic database credentials | `Granit.Vault` | | `Granit.Vault.Azure` | Azure Key Vault encryption (RSA-OAEP-256), dynamic credentials from Key Vault Secrets | `Granit.Vault` | | `Granit.Vault.Aws` | AWS KMS encryption, dynamic credentials from Secrets Manager | `Granit.Vault` | | `Granit.Vault.GoogleCloud` | Google Cloud KMS symmetric encryption, dynamic credentials from Secret Manager | `Granit.Vault` | ## Dependency graph [Section titled “Dependency graph”](#dependency-graph) ``` graph TD VH[Granit.Vault.HashiCorp] --> V[Granit.Vault] VA[Granit.Vault.Azure] --> V VAW[Granit.Vault.Aws] --> V VGC[Granit.Vault.GoogleCloud] --> V V --> E[Granit.Encryption] E --> C[Granit.Core] ``` ## Granit.Encryption [Section titled “Granit.Encryption”](#granitencryption) ### Setup [Section titled “Setup”](#setup) ```csharp [DependsOn(typeof(GranitEncryptionModule))] public class AppModule : GranitModule { } ``` Registers `IStringEncryptionService` with the AES provider by default. No external dependencies required. ### IStringEncryptionService [Section titled “IStringEncryptionService”](#istringencryptionservice) The main abstraction for encrypting and decrypting strings: ```csharp public interface IStringEncryptionService { string Encrypt(string plainText); string? Decrypt(string cipherText); } ``` `Encrypt` returns a Base64-encoded ciphertext. `Decrypt` returns the original string, or `null` if decryption fails (wrong key, corrupted data). ### Usage [Section titled “Usage”](#usage) ```csharp public class PatientNoteService(IStringEncryptionService encryption) { public string ProtectNote(string note) => encryption.Encrypt(note); public string? RevealNote(string encryptedNote) => encryption.Decrypt(encryptedNote); } ``` ### AES-256-CBC provider [Section titled “AES-256-CBC provider”](#aes-256-cbc-provider) The default provider derives the encryption key from a passphrase using PBKDF2 and encrypts with AES-256-CBC. Each encryption operation generates a random 16-byte IV. ```json { "Encryption": { "PassPhrase": "", "KeySize": 256, "ProviderName": "Aes" } } ``` Caution The `PassPhrase` must be supplied via Vault configuration provider or a secrets manager. Never hardcode or commit passphrases. ### Provider switching [Section titled “Provider switching”](#provider-switching) The `ProviderName` option selects the active encryption provider at runtime: | Provider | Value | Backend | | --------------- | ----------------- | ------------------------------------------------------------------ | | AES (default) | `"Aes"` | Local AES-256-CBC with PBKDF2 key derivation | | Vault Transit | `"Vault"` | HashiCorp Vault Transit engine (requires `Granit.Vault.HashiCorp`) | | Azure Key Vault | `"AzureKeyVault"` | Azure Key Vault RSA-OAEP-256 (requires `Granit.Vault.Azure`) | When `ProviderName` is `"Vault"`, `IStringEncryptionService` delegates to `VaultStringEncryptionProvider`, which calls Vault Transit under the hood. *** ## Granit.Vault [Section titled “Granit.Vault”](#granitvault) `Granit.Vault` is the abstraction package. It defines `ITransitEncryptionService` and `IDatabaseCredentialProvider` — provider packages (`Granit.Vault.HashiCorp`, `Granit.Vault.Azure`, `Granit.Vault.Aws`) supply the implementations. ### ITransitEncryptionService [Section titled “ITransitEncryptionService”](#itransitencryptionservice) The transit encryption abstraction for encrypt/decrypt operations: ```csharp public interface ITransitEncryptionService { Task EncryptAsync( string keyName, string plaintext, CancellationToken cancellationToken = default); Task DecryptAsync( string keyName, string ciphertext, CancellationToken cancellationToken = default); } ``` Ciphertext follows the Vault format: `vault:v1:base64encodeddata...` ### Usage [Section titled “Usage”](#usage-1) ```csharp public class SensitiveDataService(ITransitEncryptionService transit) { public async Task ProtectSsnAsync( string ssn, CancellationToken cancellationToken) { return await transit.EncryptAsync( "pii-data", ssn, cancellationToken).ConfigureAwait(false); // Returns: "vault:v1:AbCdEf..." } public async Task RevealSsnAsync( string encryptedSsn, CancellationToken cancellationToken) { return await transit.DecryptAsync( "pii-data", encryptedSsn, cancellationToken).ConfigureAwait(false); } } ``` *** ## Granit.Vault.HashiCorp [Section titled “Granit.Vault.HashiCorp”](#granitvaulthashicorp) ### Setup [Section titled “Setup”](#setup-1) ```csharp [DependsOn(typeof(GranitVaultHashiCorpModule))] public class AppModule : GranitModule { } ``` ### Dynamic database credentials [Section titled “Dynamic database credentials”](#dynamic-database-credentials) `VaultCredentialLeaseManager` obtains short-lived database credentials from the Vault Database engine and handles automatic lease renewal at a configurable threshold (default: 75% of TTL). The connection string is updated transparently — no application restart required. ### Authentication [Section titled “Authentication”](#authentication) * Kubernetes (production) ```json { "Vault": { "Address": "https://vault.example.com", "AuthMethod": "Kubernetes", "KubernetesRole": "my-backend" } } ``` Uses the Kubernetes service account token mounted at `/var/run/secrets/kubernetes.io/serviceaccount/token`. * Token (development) ```json { "Vault": { "Address": "http://localhost:8200", "AuthMethod": "Token", "Token": "hvs.dev-only-token" } } ``` Token auth is intended for local Vault dev servers only. ### Configuration reference [Section titled “Configuration reference”](#configuration-reference) ```json { "Vault": { "Address": "https://vault.example.com", "AuthMethod": "Kubernetes", "KubernetesRole": "my-backend", "KubernetesTokenPath": "/var/run/secrets/kubernetes.io/serviceaccount/token", "DatabaseMountPoint": "database", "DatabaseRoleName": "readwrite", "TransitMountPoint": "transit", "LeaseRenewalThreshold": 0.75 }, "Encryption": { "PassPhrase": "", "KeySize": 256, "ProviderName": "Aes", "VaultKeyName": "string-encryption" } } ``` | Property | Default | Description | | ----------------------------- | --------------------- | ----------------------------------------------- | | `Vault.Address` | — | Vault server URL | | `Vault.AuthMethod` | `"Kubernetes"` | `"Kubernetes"` or `"Token"` | | `Vault.KubernetesRole` | `"my-backend"` | Vault Kubernetes auth role | | `Vault.DatabaseMountPoint` | `"database"` | Database secrets engine mount point | | `Vault.DatabaseRoleName` | `"readwrite"` | Database role for dynamic credentials | | `Vault.TransitMountPoint` | `"transit"` | Transit secrets engine mount point | | `Vault.LeaseRenewalThreshold` | `0.75` | Renew lease at this fraction of TTL | | `Encryption.PassPhrase` | — | AES passphrase (via secrets manager) | | `Encryption.KeySize` | `256` | AES key size in bits | | `Encryption.ProviderName` | `"Aes"` | Active provider: `"Aes"` or `"Vault"` | | `Encryption.VaultKeyName` | `"string-encryption"` | Transit key name for `IStringEncryptionService` | ### Health check [Section titled “Health check”](#health-check) `AddGranitVaultHashiCorpHealthCheck` registers a Vault health check that verifies connectivity and authentication status, tagged `["readiness"]` for Kubernetes probe integration. *** ## Granit.Vault.Azure [Section titled “Granit.Vault.Azure”](#granitvaultazure) ### Setup [Section titled “Setup”](#setup-2) ```csharp [DependsOn(typeof(GranitVaultAzureModule))] public class AppModule : GranitModule { } ``` ### Azure Key Vault encryption [Section titled “Azure Key Vault encryption”](#azure-key-vault-encryption) Uses Azure Key Vault’s `CryptographyClient` for RSA-OAEP-256 encrypt/decrypt operations. Registered as an `IStringEncryptionProvider` with key `"AzureKeyVault"`. ### Dynamic database credentials [Section titled “Dynamic database credentials”](#dynamic-database-credentials-1) `AzureSecretsCredentialProvider` reads database credentials from a Key Vault secret (JSON: `{"username": "...", "password": "..."}`) and polls for version changes at a configurable interval. Credential rotation is detected automatically. ### Authentication [Section titled “Authentication”](#authentication-1) * Managed Identity (production) ```json { "Vault": { "Azure": { "VaultUri": "https://my-vault.vault.azure.net/" } } } ``` Uses `DefaultAzureCredential` — Managed Identity in Kubernetes, `az login` locally. * Configuration reference ```json { "Vault": { "Azure": { "VaultUri": "https://my-vault.vault.azure.net/", "EncryptionKeyName": "string-encryption", "EncryptionAlgorithm": "RSA-OAEP-256", "DatabaseSecretName": "db-credentials", "RotationCheckIntervalMinutes": 5, "TimeoutSeconds": 30 } } } ``` | Property | Default | Description | | ------------------------------------------ | --------------------- | ------------------------------------------------ | | `Vault:Azure:VaultUri` | — | Azure Key Vault URI | | `Vault:Azure:EncryptionKeyName` | `"string-encryption"` | Key name for encrypt/decrypt | | `Vault:Azure:EncryptionAlgorithm` | `"RSA-OAEP-256"` | Algorithm (`RSA-OAEP-256`, `RSA-OAEP`, `RSA1_5`) | | `Vault:Azure:DatabaseSecretName` | `null` | Secret name for DB credentials (omit to disable) | | `Vault:Azure:RotationCheckIntervalMinutes` | `5` | Secret rotation polling interval | | `Vault:Azure:TimeoutSeconds` | `30` | Azure SDK operation timeout | ### Health check [Section titled “Health check”](#health-check-1) Granit.Vault.Azure registers a health check that retrieves the configured key to verify connectivity and key availability, tagged `["readiness"]`. *** ## Granit.Vault.GoogleCloud [Section titled “Granit.Vault.GoogleCloud”](#granitvaultgooglecloud) ### Setup [Section titled “Setup”](#setup-3) ```csharp [DependsOn(typeof(GranitVaultGoogleCloudModule))] public class AppModule : GranitModule { } ``` ### Google Cloud KMS encryption [Section titled “Google Cloud KMS encryption”](#google-cloud-kms-encryption) Uses Cloud KMS `CryptoKeyVersion` for symmetric AES-256-GCM encrypt/decrypt operations. Registered as an `IStringEncryptionProvider` with key `"CloudKms"`. ### Dynamic database credentials [Section titled “Dynamic database credentials”](#dynamic-database-credentials-2) `SecretManagerCredentialProvider` reads database credentials from a Secret Manager secret (JSON: `{"username": "...", "password": "..."}`) and polls for version changes at a configurable interval. Credential rotation is detected automatically. ### Authentication [Section titled “Authentication”](#authentication-2) * Workload Identity (production) ```json { "Vault": { "GoogleCloud": { "ProjectId": "my-project", "Location": "europe-west1", "KeyRing": "my-keyring", "CryptoKey": "string-encryption" } } } ``` Uses Application Default Credentials — Workload Identity in GKE, `gcloud auth` locally. * Configuration reference ```json { "Vault": { "GoogleCloud": { "ProjectId": "my-project", "Location": "europe-west1", "KeyRing": "my-keyring", "CryptoKey": "string-encryption", "DatabaseSecretName": "db-credentials", "RotationCheckIntervalMinutes": 5, "CredentialFilePath": null, "TimeoutSeconds": 30 } } } ``` | Property | Default | Description | | ------------------------------------------------ | ------- | --------------------------------------------------------------- | | `Vault:GoogleCloud:ProjectId` | — | GCP project ID (required) | | `Vault:GoogleCloud:Location` | — | Cloud KMS location, e.g. `"europe-west1"` (required) | | `Vault:GoogleCloud:KeyRing` | — | Cloud KMS key ring name (required) | | `Vault:GoogleCloud:CryptoKey` | — | Cloud KMS crypto key for transit encryption (required) | | `Vault:GoogleCloud:DatabaseSecretName` | `null` | Secret Manager secret name for DB credentials (omit to disable) | | `Vault:GoogleCloud:RotationCheckIntervalMinutes` | `5` | Secret rotation polling interval | | `Vault:GoogleCloud:CredentialFilePath` | `null` | Service account key JSON; ADC when null | | `Vault:GoogleCloud:TimeoutSeconds` | `30` | API call timeout | ### Health check [Section titled “Health check”](#health-check-2) `AddGranitCloudKmsHealthCheck` registers a health check that verifies KMS key accessibility, tagged `["readiness"]`. ## Public API summary [Section titled “Public API summary”](#public-api-summary) | Category | Key types | Package | | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | | Modules | `GranitEncryptionModule`, `GranitVaultHashiCorpModule`, `GranitVaultAzureModule`, `GranitVaultAwsModule`, `GranitVaultGoogleCloudModule` | — | | Encryption | `IStringEncryptionService` (`Encrypt` / `Decrypt`) | `Granit.Encryption` | | Provider | `IStringEncryptionProvider`, `AesStringEncryptionProvider` | `Granit.Encryption` | | Transit | `ITransitEncryptionService` (`EncryptAsync` / `DecryptAsync`) | `Granit.Vault` | | Credentials | `IDatabaseCredentialProvider` | `Granit.Vault` | | HashiCorp Provider | `VaultStringEncryptionProvider`, `VaultCredentialLeaseManager` | `Granit.Vault.HashiCorp` | | HashiCorp Options | `HashiCorpVaultOptions` | `Granit.Vault.HashiCorp` | | Options | `StringEncryptionOptions` | `Granit.Encryption` | | Azure Provider | `AzureKeyVaultStringEncryptionProvider`, `IAzureKeyVaultTransitEncryptionService`, `IAzureDatabaseCredentialProvider` | `Granit.Vault.Azure` | | Azure Options | `AzureKeyVaultOptions` | `Granit.Vault.Azure` | | Google Cloud Provider | `CloudKmsTransitEncryptionService`, `CloudKmsStringEncryptionProvider`, `SecretManagerCredentialProvider` | `Granit.Vault.GoogleCloud` | | Google Cloud Options | `GoogleCloudVaultOptions` | `Granit.Vault.GoogleCloud` | | Extensions | `AddGranitEncryption()`, `AddGranitVaultHashiCorp()`, `AddGranitVaultAzure()`, `AddGranitVaultAws()`, `AddGranitVaultGoogleCloud()` | — | ## See also [Section titled “See also”](#see-also) * [Caching module](./caching/) — AES-256 encryption for cached GDPR-sensitive data * [Security module](./security/) — Authorization and current user abstractions * [Persistence module](./persistence/) — EF Core interceptors, dynamic credentials integration # Webhooks > Outbound webhook dispatch with HMAC-SHA256 signed deliveries, retry policies, and ISO 27001-compliant audit trails Granit.Webhooks provides outbound webhook dispatch with HMAC-SHA256 signed deliveries, retry policies, and ISO 27001-compliant audit trails. ## Package structure [Section titled “Package structure”](#package-structure) | Package | Role | Depends on | | ------------------------------------- | ------------------------------------------------- | --------------------------------------- | | `Granit.Webhooks` | `IWebhookPublisher`, HMAC delivery, domain events | `Granit.Timing` | | `Granit.Webhooks.EntityFrameworkCore` | Isolated DbContext for subscriptions + deliveries | `Granit.Webhooks`, `Granit.Persistence` | | `Granit.Webhooks.Wolverine` | Durable outbox dispatch, retry policies | `Granit.Webhooks`, `Granit.Wolverine` | ## Dependency graph [Section titled “Dependency graph”](#dependency-graph) ``` graph TD W[Granit.Webhooks] --> T[Granit.Timing] WEF[Granit.Webhooks.EntityFrameworkCore] --> W WEF --> P[Granit.Persistence] WW[Granit.Webhooks.Wolverine] --> W WW --> WLV[Granit.Wolverine] ``` *** ## Setup [Section titled “Setup”](#setup) * Development (in-memory) ```csharp [DependsOn(typeof(GranitWebhooksModule))] public class AppModule : GranitModule { } ``` Uses in-memory subscription store and Channel-based in-process dispatch. Suitable for development and integration tests. * Production (Wolverine + EF Core) ```csharp [DependsOn( typeof(GranitWebhooksWolverineModule), typeof(GranitWebhooksEntityFrameworkCoreModule))] public class AppModule : GranitModule { } ``` ```csharp builder.AddGranitWebhooksEntityFrameworkCore( opts => opts.UseNpgsql(connectionString)); ``` Wolverine provides durable outbox dispatch with exponential backoff retries. EF Core persists subscriptions and delivery attempts for ISO 27001 audit. ## Publishing a webhook [Section titled “Publishing a webhook”](#publishing-a-webhook) Inject `IWebhookPublisher` and call `PublishAsync` with a logical event type and payload: ```csharp public class DocumentUploadedHandler(IWebhookPublisher webhookPublisher) { public async Task HandleAsync( DocumentUploaded evt, CancellationToken cancellationToken) { await webhookPublisher.PublishAsync( "document.uploaded", new { evt.DocumentId, evt.FileName, evt.UploadedBy }, cancellationToken).ConfigureAwait(false); } } ``` The publisher serializes the payload into a `WebhookEnvelope`, captures the ambient tenant context, and dispatches a `WebhookTrigger` to the outbox. The fanout handler resolves matching subscriptions and enqueues one `SendWebhookCommand` per subscriber. ## Webhook envelope [Section titled “Webhook envelope”](#webhook-envelope) Every HTTP POST to a subscriber endpoint carries a standardized JSON envelope: ```json { "eventId": "a1b2c3d4-...", "eventType": "document.uploaded", "tenantId": "d4e5f6a7-...", "timestamp": "2026-03-13T10:30:00Z", "apiVersion": "1.0", "data": { "documentId": "f8e9d0c1-...", "fileName": "report.pdf", "uploadedBy": "user-42" } } ``` The `eventId` is stable across retry attempts, allowing subscribers to deduplicate. ## HMAC signature [Section titled “HMAC signature”](#hmac-signature) Each delivery is signed with the subscription’s secret using HMAC-SHA256 in Stripe format: ```plaintext x-granit-signature: t=1710323400,v1=5d3b2a1c... ``` The signed payload is `{unix_timestamp}.{body_json}`. Subscribers should: 1. Extract the timestamp and signature from the header 2. Recompute the HMAC using the shared secret 3. Constant-time compare the signatures 4. Reject requests older than 5 minutes (replay protection) ## Retry and suspension [Section titled “Retry and suspension”](#retry-and-suspension) * Wolverine (production) Exponential backoff: 30s, 2m, 10m, 30m, 2h, 12h. After 6 retries (\~14h30 total), the message moves to the Dead-Letter Queue and the subscription is suspended. * Channel (development) Channel-based dispatch retries 3 times with linear backoff. No persistence — failed deliveries are lost on restart. When consecutive failures exceed the threshold, the subscription transitions: | Failures | Status | Domain event | | ------------------- | ------------- | -------------------------------- | | 0 | `Active` | — | | Threshold reached | `Suspended` | `WebhookSubscriptionSuspended` | | Non-retriable (4xx) | `Deactivated` | `WebhookSubscriptionDeactivated` | Suspension records `SuspendedAt` and `SuspendedBy` for ISO 27001 audit compliance. ## Delivery audit trail [Section titled “Delivery audit trail”](#delivery-audit-trail) `WebhookDeliveryAttempt` is an INSERT-only entity (no soft-delete) that records every delivery attempt: HTTP status, duration, payload hash (SHA-256), and optional full payload when `StorePayload` is enabled. Caution Enabling `StorePayload` persists the full JSON body in clear text. Ensure encryption at rest is configured on the database and that your DPO has validated this setting against GDPR data-minimization requirements. ## Configuration reference [Section titled “Configuration reference”](#configuration-reference) ```json { "Webhooks": { "HttpTimeoutSeconds": 10, "MaxParallelDeliveries": 20, "StorePayload": false } } ``` | Property | Default | Description | | ----------------------- | ------- | --------------------------------------------------------------- | | `HttpTimeoutSeconds` | `10` | HTTP request timeout (5–120 seconds) | | `MaxParallelDeliveries` | `20` | Max parallel `SendWebhookCommand` on the delivery queue (1–100) | | `StorePayload` | `false` | Persist full JSON body alongside delivery attempts | ## Public API summary [Section titled “Public API summary”](#public-api-summary) | Category | Key types | Package | | ------------- | -------------------------------------------------------------------------------------------------- | ----------------- | | Modules | `GranitWebhooksModule`, `GranitWebhooksEntityFrameworkCoreModule`, `GranitWebhooksWolverineModule` | Webhooks | | Publisher | `IWebhookPublisher` (`PublishAsync`) | `Granit.Webhooks` | | Subscriptions | `IWebhookSubscriptionReader`, `IWebhookSubscriptionWriter`, `WebhookSubscription` | `Granit.Webhooks` | | Delivery | `WebhookDeliveryAttempt`, `WebhookEnvelope`, `IWebhookDeliveryReader`, `IWebhookDeliveryWriter` | `Granit.Webhooks` | | Events | `WebhookSubscriptionSuspended`, `WebhookSubscriptionDeactivated` | `Granit.Webhooks` | | Options | `WebhooksOptions` | `Granit.Webhooks` | | Extensions | `AddGranitWebhooks()`, `AddGranitWebhooksEntityFrameworkCore()` | — | ## See also [Section titled “See also”](#see-also) * [API & Web module](./api-web/) — Exception handling, versioning, idempotency * [Persistence module](./persistence/) — EF Core interceptors, query filters * [Wolverine module](./wolverine/) — Messaging, transactional outbox # Granit.Wolverine > Wolverine messaging, transactional outbox, domain event dispatch, context propagation, FluentValidation Granit.Wolverine integrates the [Wolverine](https://wolverinefx.net/) message bus into the module system. Domain events route to local queues, integration events persist in a transactional outbox, and tenant/user/trace context propagates automatically across async message processing. FluentValidation runs as bus middleware — invalid commands go straight to the error queue, no retry. ## Package structure [Section titled “Package structure”](#package-structure) | Package | Role | Depends on | | ----------------------------- | ----------------------------------------------------- | ---------------------------------------- | | `Granit.Wolverine` | Domain event routing, context propagation, validation | `Granit.Security` | | `Granit.Wolverine.Postgresql` | PostgreSQL transactional outbox, EF Core integration | `Granit.Wolverine`, `Granit.Persistence` | ## Dependency graph [Section titled “Dependency graph”](#dependency-graph) ``` graph TD W[Granit.Wolverine] --> S[Granit.Security] WP[Granit.Wolverine.Postgresql] --> W WP --> P[Granit.Persistence] ``` ## Setup [Section titled “Setup”](#setup) * With PostgreSQL outbox (production) ```csharp [DependsOn(typeof(GranitWolverinePostgresqlModule))] public class AppModule : GranitModule { } ``` ```json { "Wolverine": { "MaxRetryAttempts": 3, "RetryDelays": ["00:00:05", "00:00:30", "00:05:00"] }, "WolverinePostgresql": { "TransportConnectionString": "Host=db;Database=myapp;Username=app;Password=..." } } ``` * In-memory only (development) ```csharp [DependsOn(typeof(GranitWolverineModule))] public class AppModule : GranitModule { } ``` No outbox — messages are in-memory only. Lost on crash. * Per-tenant database ```csharp [DependsOn(typeof(GranitWolverinePostgresqlModule))] public class AppModule : GranitModule { public override void ConfigureServices(ServiceConfigurationContext context) { context.Builder.AddGranitWolverineWithPostgresqlPerTenant(); } } ``` Each tenant’s messages persist in their own database (strictest ISO 27001 isolation). ## Domain events vs integration events [Section titled “Domain events vs integration events”](#domain-events-vs-integration-events) | | Domain event | Integration event | | --------- | ------------------------------- | ----------------------------- | | Interface | `IDomainEvent` | `IIntegrationEvent` | | Naming | `PatientDischargedOccurred` | `BedReleasedEvent` | | Scope | In-process, same transaction | Cross-module, durable | | Transport | Local queue (`"domain-events"`) | Outbox → PostgreSQL transport | | Retry | Yes (configurable delays) | Yes (at-least-once delivery) | ```csharp public sealed record PatientDischargedOccurred( Guid PatientId, Guid BedId) : IDomainEvent; public sealed record BedReleasedEvent( Guid BedId, Guid WardId, DateTimeOffset ReleasedAt) : IIntegrationEvent; ``` ## Handler conventions [Section titled “Handler conventions”](#handler-conventions) ### Discovery [Section titled “Discovery”](#discovery) Mark handler assemblies with `[WolverineHandlerModule]` — Granit auto-discovers handlers and validators: ```csharp [assembly: WolverineHandlerModule] namespace MyApp.Handlers; public static class DischargePatientHandler { public static IEnumerable Handle( DischargePatientCommand command, PatientDbContext db) { var patient = db.Patients.Find(command.PatientId) ?? throw new EntityNotFoundException(typeof(Patient), command.PatientId); patient.Discharge(); // Domain event — same transaction yield return new PatientDischargedOccurred(patient.Id, patient.BedId); // Integration event — persisted in outbox yield return new BedReleasedEvent( patient.BedId, patient.WardId, DateTimeOffset.UtcNow); } } ``` Handlers returning `IEnumerable` produce multiple outbox messages atomically (fan-out pattern). ### FluentValidation [Section titled “FluentValidation”](#fluentvalidation) Validators run as bus middleware **before** handler execution: ```csharp public class DischargePatientCommandValidator : AbstractValidator { public DischargePatientCommandValidator() { RuleFor(x => x.PatientId).NotEmpty(); } } ``` `ValidationException` goes directly to the error queue — no retry. Other exceptions follow the retry policy. Caution Modules **without** `[WolverineHandlerModule]` must register validators manually: ```csharp services.AddGranitValidatorsFromAssemblyContaining(); ``` Without registration, `FluentValidationEndpointFilter` silently skips validation. ## Context propagation [Section titled “Context propagation”](#context-propagation) Three contexts automatically propagate through message envelopes: ``` sequenceDiagram participant HTTP as HTTP Request participant Out as OutgoingMiddleware participant Env as Message Envelope participant In as Incoming Behaviors participant Handler as Handler HTTP->>Out: TenantId, UserId, TraceId Out->>Env: X-Tenant-Id, X-User-Id, traceparent Env->>In: Read headers In->>Handler: ICurrentTenant, ICurrentUserService, Activity ``` | Header | Source | Behavior | | ------------------ | ------------------------------- | ---------------------------------------------------------------- | | `X-Tenant-Id` | `ICurrentTenant.Id` | `TenantContextBehavior` restores AsyncLocal | | `X-User-Id` | `ICurrentUserService.UserId` | `UserContextBehavior` restores via `WolverineCurrentUserService` | | `X-User-FirstName` | `ICurrentUserService.FirstName` | Propagated for audit trail | | `X-User-LastName` | `ICurrentUserService.LastName` | Propagated for audit trail | | `X-Actor-Kind` | `ICurrentUserService.ActorKind` | `User`, `ExternalSystem`, or `System` | | `X-Api-Key-Id` | `ICurrentUserService.ApiKeyId` | Service account identification | | `traceparent` | `Activity.Current?.Id` | W3C Trace Context for distributed tracing | This means `AuditedEntityInterceptor` populates `CreatedBy`/`ModifiedBy` correctly even in background handlers — the user context travels with the message. ## Transactional outbox [Section titled “Transactional outbox”](#transactional-outbox) The PostgreSQL outbox guarantees at-least-once delivery by persisting messages in the **same transaction** as business data: ``` sequenceDiagram participant H as Handler participant DB as PostgreSQL participant O as Outbox participant T as Transport H->>DB: UPDATE patients SET ... H->>O: INSERT outbox message H->>DB: COMMIT (atomic) Note over DB,O: Both succeed or both rollback O->>T: Dispatch post-commit T->>O: ACK → DELETE from outbox ``` **Transaction modes:** | Mode | Behavior | Use case | | ----------------- | ------------------------------------ | ----------------------- | | `Eager` (default) | Explicit `BeginTransactionAsync()` | ISO 27001 compliance | | `Lightweight` | `SaveChangesAsync()`-level isolation | Non-critical operations | ## Claim Check pattern [Section titled “Claim Check pattern”](#claim-check-pattern) For large payloads that shouldn’t travel through the message bus: ```csharp public class LargeReportHandler(IClaimCheckStore claimCheck) { public async Task Handle( GenerateReportCommand command, CancellationToken cancellationToken) { byte[] reportData = await GenerateReportAsync(command, cancellationToken) .ConfigureAwait(false); return await claimCheck.StorePayloadAsync( reportData, expiry: TimeSpan.FromHours(1), cancellationToken) .ConfigureAwait(false); } } // Consumer retrieves and deletes in one call var report = await claimCheck.ConsumePayloadAsync( reference, cancellationToken) .ConfigureAwait(false); ``` Register with `services.AddInMemoryClaimCheckStore()` for development. Production implementations use blob storage. ## Retry policy [Section titled “Retry policy”](#retry-policy) ```json { "Wolverine": { "RetryDelays": ["00:00:05", "00:00:30", "00:05:00"], "MaxRetryAttempts": 3 } } ``` | Property | Default | Description | | ------------------ | ----------------- | ------------------------------- | | `RetryDelays` | `[5s, 30s, 5min]` | Delay between retries | | `MaxRetryAttempts` | `3` | Max attempts before dead letter | `ValidationException` bypasses retries entirely — sent directly to the error queue. ## Wolverine is optional [Section titled “Wolverine is optional”](#wolverine-is-optional) Wolverine is **not** required to use Granit. These modules have built-in `Channel` fallbacks when Wolverine is not installed: | Module | Without Wolverine | With Wolverine | | ------------------------------- | ----------------- | -------------- | | `Granit.BackgroundJobs` | In-memory Channel | Durable outbox | | `Granit.Notifications` | In-memory Channel | Durable outbox | | `Granit.Webhooks` | In-memory Channel | Durable outbox | | `Granit.DataExchange` | In-memory Channel | Durable outbox | | `Granit.Persistence.Migrations` | In-memory Channel | Durable outbox | ## Public API summary [Section titled “Public API summary”](#public-api-summary) | Category | Key types | Package | | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | | Module | `GranitWolverineModule`, `GranitWolverinePostgresqlModule` | — | | Options | `WolverineMessagingOptions`, `WolverinePostgresqlOptions` | — | | Context | `OutgoingContextMiddleware`, `TenantContextBehavior`, `UserContextBehavior`, `TraceContextBehavior` | `Granit.Wolverine` | | User service | `WolverineCurrentUserService` (internal, implements `ICurrentUserService`) | `Granit.Wolverine` | | Claim Check | `IClaimCheckStore`, `ClaimCheckReference` | `Granit.Wolverine` | | Attributes | `[WolverineHandlerModule]` | `Granit.Wolverine` | | Extensions | `AddGranitWolverine()`, `AddGranitWolverineWithPostgresql()`, `AddGranitWolverineWithPostgresqlPerTenant()`, `AddInMemoryClaimCheckStore()` | — | ## See also [Section titled “See also”](#see-also) * [Core module](./core/) — `IDomainEvent`, `IIntegrationEvent`, `IDomainEventDispatcher` * [Persistence module](./persistence/) — `DomainEventDispatcherInterceptor`, transactional outbox * [Multi-tenancy module](./multi-tenancy/) — Tenant context propagation * [Security module](./security/) — `ICurrentUserService` propagation * [API Reference](/api/Granit.Wolverine.html) (auto-generated from XML docs) # Granit.Workflow > Finite state machine engine, publication lifecycle, approval routing, ISO 27001 audit trail Granit.Workflow provides a type-safe finite state machine (FSM) engine for managing entity lifecycles. Workflow definitions are built via a fluent API, validated at build time (no duplicate transitions, no unreachable states), and stored as immutable singletons. Transitions support permission checking, Odoo-style approval routing, and an INSERT-only audit trail for ISO 27001 compliance. ## Package structure [Section titled “Package structure”](#package-structure) | Package | Role | Depends on | | ------------------------------------- | --------------------------------------------------------------------------------------- | ----------------------------------------- | | `Granit.Workflow` | FSM definitions, `IWorkflowManager`, domain events | `Granit.Timing` | | `Granit.Workflow.EntityFrameworkCore` | `WorkflowTransitionInterceptor`, `IWorkflowHistoryQuery`, `IWorkflowTransitionRecorder` | `Granit.Workflow`, `Granit.Persistence` | | `Granit.Workflow.Endpoints` | `GET /{entityType}/{entityId}/history` (paginated audit trail) | `Granit.Workflow`, `Granit.Authorization` | | `Granit.Workflow.Notifications` | Approval notifications (InApp + Email), state change notifications (InApp + SignalR) | `Granit.Workflow` | | `Granit.Templating.Workflow` | Replaces `NullTemplateTransitionHook` with `WorkflowTemplateTransitionHook` | `Granit.Templating`, `Granit.Workflow` | ## Dependency graph [Section titled “Dependency graph”](#dependency-graph) ``` graph TD W[Granit.Workflow] --> T[Granit.Timing] WEF[Granit.Workflow.EntityFrameworkCore] --> W WEF --> P[Granit.Persistence] WE[Granit.Workflow.Endpoints] --> W WE --> A[Granit.Authorization] WN[Granit.Workflow.Notifications] --> W TW[Granit.Templating.Workflow] --> W TW --> TM[Granit.Templating] ``` ## Setup [Section titled “Setup”](#setup) * Full stack (production) ```csharp [DependsOn(typeof(GranitWorkflowEntityFrameworkCoreModule))] [DependsOn(typeof(GranitWorkflowEndpointsModule))] [DependsOn(typeof(GranitWorkflowNotificationsModule))] public class AppModule : GranitModule { public override void ConfigureServices(ServiceConfigurationContext context) { context.Services.AddGranitWorkflowEntityFrameworkCore(); context.Services.AddGranitWorkflowEndpoints(); context.Services.AddGranitWorkflowNotifications(); } } ``` Program.cs ```csharp app.MapWorkflowEndpoints(); ``` * Core only (no persistence) ```csharp [DependsOn(typeof(GranitWorkflowModule))] public class AppModule : GranitModule { } ``` Use this when you only need the FSM engine and domain events without EF Core persistence or HTTP endpoints. ## Defining a workflow [Section titled “Defining a workflow”](#defining-a-workflow) Workflow definitions are generic over an `enum` state type. The fluent builder validates the graph at build time: initial state must be set, at least one transition must exist, no duplicate `From+To` pairs, and all states must be reachable from the initial state via BFS. ```csharp public enum InvoiceStatus { Draft, PendingApproval, Approved, Rejected, Paid, } public static class InvoiceWorkflow { public static WorkflowDefinition Default { get; } = WorkflowDefinition.Create(builder => builder .InitialState(InvoiceStatus.Draft) .Transition(InvoiceStatus.Draft, InvoiceStatus.PendingApproval, t => t .Named("Submit for approval") .RequiresPermission("invoice.submit")) .Transition(InvoiceStatus.PendingApproval, InvoiceStatus.Approved, t => t .Named("Approve") .RequiresPermission("invoice.approve") .RequiresApproval()) .Transition(InvoiceStatus.PendingApproval, InvoiceStatus.Rejected, t => t .Named("Reject") .RequiresPermission("invoice.approve")) .Transition(InvoiceStatus.Approved, InvoiceStatus.Paid, t => t .Named("Mark as paid") .RequiresPermission("invoice.pay"))); } ``` Register the definition as a singleton: ```csharp services.AddSingleton>(InvoiceWorkflow.Default); services.AddScoped, WorkflowManager>(); ``` ## Built-in publication workflow [Section titled “Built-in publication workflow”](#built-in-publication-workflow) Granit ships a pre-built `PublicationWorkflow.Default` for the standard publication lifecycle. It uses `WorkflowLifecycleStatus` as the state enum: ``` stateDiagram-v2 [*] --> Draft Draft --> PendingReview : Submit for review Draft --> Published : Direct publish (approval) PendingReview --> Published : Approve and publish Published --> Archived : Archive Published --> Draft : Create new version ``` | State | Description | | --------------- | ------------------------------------------------------------------------------- | | `Draft` | Being edited. Filtered by `IPublishable` (not visible in standard queries). | | `PendingReview` | Awaiting approval from a user with the required permission. | | `Published` | Active published version. Exactly one per `BusinessId` (unique filtered index). | | `Archived` | Former version, preserved for ISO 27001 audit trail (3-year retention). | ```csharp // Use the built-in definition directly services.AddSingleton>( PublicationWorkflow.Default); ``` ## Transitioning state [Section titled “Transitioning state”](#transitioning-state) `IWorkflowManager` orchestrates transitions with permission checking and approval routing: ```csharp public static class ApproveInvoiceHandler { public static async Task Handle( Guid invoiceId, InvoiceDbContext db, IWorkflowManager workflowManager, CancellationToken cancellationToken) { Invoice invoice = await db.Invoices.FindAsync([invoiceId], cancellationToken) ?? throw new EntityNotFoundException(typeof(Invoice), invoiceId); TransitionResult result = await workflowManager.TransitionAsync( invoice.Status, InvoiceStatus.Approved, new TransitionContext { Comment = "Approved per department policy" }, cancellationToken).ConfigureAwait(false); return result.Outcome switch { TransitionOutcome.Completed => TypedResults.Ok(), TransitionOutcome.ApprovalRequested => TypedResults.Accepted( value: "Routed to approval"), TransitionOutcome.Denied => TypedResults.Problem( detail: "Insufficient permissions", statusCode: 403), TransitionOutcome.InvalidTransition => TypedResults.Problem( detail: "Invalid transition", statusCode: 422), _ => TypedResults.Problem(detail: "Unexpected outcome", statusCode: 500), }; } } ``` ### Transition outcomes [Section titled “Transition outcomes”](#transition-outcomes) | Outcome | `Succeeded` | Description | | ------------------- | ----------- | ------------------------------------------------------ | | `Completed` | `true` | Entity moved to the requested target state. | | `ApprovalRequested` | `true` | Entity routed to `PendingReview` — approvers notified. | | `Denied` | `false` | User lacks permission, no approval routing available. | | `InvalidTransition` | `false` | No transition defined from current state to target. | ## Approval routing [Section titled “Approval routing”](#approval-routing) When a user triggers a transition that requires a permission they lack, and the transition has `RequiresApproval()` enabled, the workflow manager routes the entity to a pending review state and publishes a `WorkflowApprovalRequested` domain event: ``` sequenceDiagram participant U as User (no publish permission) participant WM as IWorkflowManager participant PC as IWorkflowPermissionChecker participant EV as Domain Events U->>WM: TransitionAsync(Draft → Published) WM->>PC: IsGrantedAsync("workflow.publish") PC-->>WM: false Note over WM: RequiresApproval = true WM-->>U: ApprovalRequested (→ PendingReview) WM->>EV: WorkflowApprovalRequested Note over EV: Notifications module sends InApp + Email to approvers ``` The `IApproverResolver` interface determines who receives the notification: | Implementation | Behavior | | -------------------------------- | ----------------------------------------------------------------------------- | | `NullApproverResolver` (default) | Returns empty list — no notifications sent. | | `IdentityApproverResolver` | Queries users who hold the required permission via `Granit.Identity`. | | Custom | Register your own for department-based, hierarchy-based, or delegation logic. | ```csharp // Register a custom approver resolver services.AddWorkflowApproverResolver(); ``` ## IWorkflowStateful marker [Section titled “IWorkflowStateful marker”](#iworkflowstateful-marker) Entities implementing `IWorkflowStateful` get automatic audit trail recording via the `WorkflowTransitionInterceptor`. The interface uses C# 11+ static abstract members to avoid instance-level overhead: ```csharp public class Invoice : AuditedEntity, IWorkflowStateful { public InvoiceStatus Status { get; set; } public decimal Amount { get; set; } // Static abstract members — resolved via reflection by the interceptor static string IWorkflowStateful.StatusPropertyName => nameof(Status); static string IWorkflowStateful.WorkflowEntityType => "Invoice"; public string GetWorkflowEntityId() => Id.ToString(); } ``` For entities that combine versioning with workflow (Odoo-style documents), inherit from `VersionedWorkflowEntity`: ```csharp public class DocumentRevision : VersionedWorkflowEntity { public string Title { get; set; } = string.Empty; public string Content { get; set; } = string.Empty; // Only need to provide the entity type name static string IWorkflowStateful.WorkflowEntityType => "Document"; } ``` `VersionedWorkflowEntity` provides `BusinessId`, `Version`, `LifecycleStatus`, and `IsPublished` — the interceptor keeps `IsPublished` in sync with the lifecycle status. ## ISO 27001 audit trail [Section titled “ISO 27001 audit trail”](#iso-27001-audit-trail) ### WorkflowTransitionRecord [Section titled “WorkflowTransitionRecord”](#workflowtransitionrecord) Every state change produces an INSERT-only `WorkflowTransitionRecord`. These records are never modified or deleted (soft-delete is explicitly prohibited): | Column | Type | Description | | ---------------- | ---------------- | ----------------------------------------------------- | | `Id` | `Guid` | Sequential GUID (clustered index). | | `EntityType` | `string` | Logical entity type (e.g. `"Invoice"`, `"Document"`). | | `EntityId` | `string` | Entity identifier (string for polymorphism). | | `PreviousState` | `string` | State before transition. | | `NewState` | `string` | State after transition. | | `TransitionedAt` | `DateTimeOffset` | UTC timestamp from `IClock.Now`. | | `TransitionedBy` | `string` | User ID or `"system"` for automated transitions. | | `Comment` | `string?` | Optional regulatory justification. | | `TenantId` | `Guid?` | Tenant context (null when multi-tenancy inactive). | ### WorkflowTransitionInterceptor [Section titled “WorkflowTransitionInterceptor”](#workflowtransitioninterceptor) The interceptor hooks into EF Core `SaveChanges` and detects state changes by comparing `OriginalValues` vs `CurrentValues` on the property identified by `IWorkflowStateful.StatusPropertyName`. When a difference is detected, a new `WorkflowTransitionRecord` is added to the same transaction. The interceptor also synchronizes `IPublishable.IsPublished` for entities implementing both `IPublishable` and `IWorkflowStateful` — set to `true` only when the status is `WorkflowLifecycleStatus.Published`. ### AsyncLocal comment propagation [Section titled “AsyncLocal comment propagation”](#asynclocal-comment-propagation) Attach a regulatory comment to the current transition via `WorkflowTransitionContext`: ```csharp using (WorkflowTransitionContext.SetComment("Validated by medical director per ISO 27001")) { invoice.Status = InvoiceStatus.Approved; await dbContext.SaveChangesAsync(cancellationToken); } // Comment is automatically cleared when the scope disposes ``` The interceptor reads `WorkflowTransitionContext.Current?.Comment` and stores it in the transition record. ## EF Core integration [Section titled “EF Core integration”](#ef-core-integration) The host application’s `DbContext` must implement `IWorkflowDbContext` and call the model configuration extension: ```csharp public class AppDbContext : DbContext, IWorkflowDbContext { public DbSet WorkflowTransitionRecords => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); modelBuilder.ConfigureWorkflowModule(); modelBuilder.ApplyGranitConventions(currentTenant, dataFilter); } } ``` Register the EF Core services: ```csharp services.AddGranitWorkflowEntityFrameworkCore(); ``` This registers: * `WorkflowTransitionInterceptor` (Scoped, ordered after `AuditedEntityInterceptor`) * `IWorkflowTransitionRecorder` → `EfWorkflowTransitionRecorder` * `IWorkflowHistoryQuery` → `DefaultWorkflowHistoryQuery` ## Endpoints [Section titled “Endpoints”](#endpoints) `Granit.Workflow.Endpoints` exposes a single read endpoint: | Method | Route | Permission | Description | | ------ | ---------------------------------- | ----------------------- | ------------------------------- | | `GET` | `/{entityType}/{entityId}/history` | `Workflow.History.Read` | Paginated ISO 27001 audit trail | Program.cs ```csharp app.MapWorkflowEndpoints(); ``` Query parameters: `page` (default 1), `pageSize` (default 20, max 100). ## Notifications [Section titled “Notifications”](#notifications) `Granit.Workflow.Notifications` provides two notification types: | Notification type | Severity | Default channels | Trigger | | -------------------------------------- | -------- | ---------------- | ---------------------------------------- | | `WorkflowApprovalNotificationType` | Warning | InApp + Email | `WorkflowApprovalRequested` domain event | | `WorkflowStateChangedNotificationType` | Info | InApp + SignalR | `WorkflowStateChangedEvent` domain event | Wolverine handlers consume the domain events and dispatch notifications via the `Granit.Notifications` engine. ## Domain events [Section titled “Domain events”](#domain-events) | Event | Published when | Payload | | ------------------------------ | --------------------------------------------------- | ---------------------------------------------------------------------------- | | `WorkflowTransitioned` | Transition completes (generic, app-level) | `EntityType`, `EntityId`, `PreviousState`, `NewState`, `TransitionedBy` | | `WorkflowStateChangedEvent` | Transition completes (non-generic, framework-level) | `EntityType`, `EntityId`, `PreviousState`, `NewState`, `TransitionedBy` | | `WorkflowApprovalRequested` | Transition routed to approval | `EntityType`, `EntityId`, `RequestedBy`, `TargetState`, `RequiredPermission` | ## Public API summary [Section titled “Public API summary”](#public-api-summary) | Category | Key types | Package | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | | Module | `GranitWorkflowModule`, `GranitWorkflowEntityFrameworkCoreModule`, `GranitWorkflowEndpointsModule`, `GranitWorkflowNotificationsModule`, `GranitTemplatingWorkflowModule` | — | | Definition | `WorkflowDefinition`, `WorkflowDefinitionBuilder`, `IWorkflowDefinition` | `Granit.Workflow` | | Transition | `WorkflowTransition`, `TransitionBuilder`, `TransitionResult`, `TransitionOutcome` | `Granit.Workflow` | | Manager | `IWorkflowManager`, `WorkflowManager` | `Granit.Workflow` | | Marker | `IWorkflowStateful`, `VersionedWorkflowEntity` | `Granit.Workflow` | | Context | `WorkflowTransitionContext`, `TransitionContext` | `Granit.Workflow` | | Permission | `IWorkflowPermissionChecker` | `Granit.Workflow` | | Audit | `WorkflowTransitionRecord`, `IWorkflowTransitionRecorder`, `IWorkflowHistoryQuery` | `Granit.Workflow` / `.EntityFrameworkCore` | | Domain | `WorkflowLifecycleStatus`, `PublicationWorkflow` | `Granit.Workflow` | | Events | `WorkflowTransitioned`, `WorkflowStateChangedEvent`, `WorkflowApprovalRequested` | `Granit.Workflow` | | EF Core | `IWorkflowDbContext`, `WorkflowTransitionInterceptor` | `Granit.Workflow.EntityFrameworkCore` | | Notifications | `IApproverResolver`, `WorkflowApprovalNotificationType`, `WorkflowStateChangedNotificationType` | `Granit.Workflow.Notifications` | | Extensions | `AddGranitWorkflow()`, `AddGranitWorkflowEntityFrameworkCore()`, `AddGranitWorkflowEndpoints()`, `AddGranitWorkflowNotifications()`, `MapWorkflowEndpoints()` | — | ## See also [Section titled “See also”](#see-also) * [Core module](./core/) — `IDomainEvent`, module system, `IPublishable` * [Persistence module](./persistence/) — `AuditedEntityInterceptor`, `ApplyGranitConventions` * [Security module](./security/) — `ICurrentUserService`, permission checking * [Notifications module](./notifications/) — Notification engine, channels * [Wolverine module](./wolverine/) — Domain event routing, transactional outbox * [API Reference](/api/Granit.Workflow.html) (auto-generated from XML docs) # Provider Compatibility > Database, cache, storage, and infrastructure provider support matrix for Granit modules Granit is developed and tested against PostgreSQL and the Grafana LGTM observability stack. Other providers are supported through EF Core’s provider abstraction or dedicated packages, but receive varying levels of testing. This page documents the current support status for each infrastructure dimension. ## Legend [Section titled “Legend”](#legend) | Status | Meaning | | -------------------- | -------------------------------------------------------------------------- | | **Supported** | Actively tested in CI (unit + integration tests) | | **Compatible** | Uses provider-agnostic EF Core APIs; expected to work but not tested in CI | | **Partial** | Known limitations or missing features for this provider | | **Provider package** | Requires installing a dedicated Granit provider package | | **N/A** | Module does not use this infrastructure | ## Summary matrix [Section titled “Summary matrix”](#summary-matrix) | Infrastructure | Primary (tested) | Secondary (compatible) | | ----------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | Database | PostgreSQL | SQL Server, SQLite (via EF Core) | | Cache | Memory (`IDistributedCache`) | Redis (StackExchange.Redis), HybridCache (L1+L2) | | Blob storage | S3-compatible (MinIO, AWS S3) | Azure Blob, Google Cloud Storage | | Identity provider | Keycloak | Entra ID (Azure AD), AWS Cognito, Google Cloud Identity Platform, custom (`IIdentityProvider`) | | Messaging | PostgreSQL (Wolverine) | SQL Server (Wolverine), RabbitMQ (Wolverine) | | Observability | Grafana LGTM (Loki/Tempo/Mimir) | Any OTLP-compatible backend | | Encryption | HashiCorp Vault Transit | Azure Key Vault (`Granit.Vault.Azure`), AWS KMS (`Granit.Vault.Aws`), Google Cloud KMS (`Granit.Vault.GoogleCloud`) | ## Database providers [Section titled “Database providers”](#database-providers) All `*.EntityFrameworkCore` packages use EF Core’s provider-agnostic APIs and do not contain provider-specific SQL. The database provider is chosen by the application at registration time (e.g., `opts.UseNpgsql(connectionString)`). PostgreSQL is the only provider with integration tests. SQL Server is expected to work based on EF Core compatibility, but is not verified in CI. SQLite may work for development scenarios but lacks support for some features used by Granit (e.g., `DateTimeOffset` handling, concurrent migrations). | Module | PostgreSQL | SQL Server | SQLite | | ------------------------------------------------- | ---------- | ---------- | ---------- | | Granit.Persistence | Supported | Compatible | Compatible | | Granit.Persistence.Migrations | Supported | Compatible | Not tested | | Granit.Authorization.EntityFrameworkCore | Supported | Compatible | Compatible | | Granit.Settings.EntityFrameworkCore | Supported | Compatible | Compatible | | Granit.Features.EntityFrameworkCore | Supported | Compatible | Compatible | | Granit.Identity.EntityFrameworkCore | Supported | Compatible | Compatible | | Granit.Localization.EntityFrameworkCore | Supported | Compatible | Compatible | | Granit.ReferenceData.EntityFrameworkCore | Supported | Compatible | Compatible | | Granit.BlobStorage.EntityFrameworkCore | Supported | Compatible | Compatible | | Granit.Notifications.EntityFrameworkCore | Supported | Compatible | Compatible | | Granit.Webhooks.EntityFrameworkCore | Supported | Compatible | Compatible | | Granit.Workflow\.EntityFrameworkCore | Supported | Compatible | Compatible | | Granit.Timeline.EntityFrameworkCore | Supported | Compatible | Compatible | | Granit.Templating.EntityFrameworkCore | Supported | Compatible | Compatible | | Granit.DataExchange.EntityFrameworkCore | Supported | Compatible | Compatible | | Granit.BackgroundJobs.EntityFrameworkCore | Supported | Compatible | Compatible | | Granit.Querying.EntityFrameworkCore | Supported | Compatible | Compatible | | Granit.Authentication.ApiKeys.EntityFrameworkCore | Supported | Compatible | Compatible | ### Wolverine database transports [Section titled “Wolverine database transports”](#wolverine-database-transports) Wolverine’s transactional outbox and durable messaging require a provider-specific package. These are **not** interchangeable via EF Core abstractions. | Package | PostgreSQL | SQL Server | | --------------------------- | ---------- | ---------------- | | Granit.Wolverine.Postgresql | Supported | N/A | | Granit.Wolverine.SqlServer | N/A | Provider package | `Granit.Wolverine.Postgresql` is the primary and fully tested transport. `Granit.Wolverine.SqlServer` exists as a dedicated package but receives less testing. ### Notes on provider selection [Section titled “Notes on provider selection”](#notes-on-provider-selection) * **PostgreSQL** is the recommended and fully supported provider. All integration tests, CI pipelines, and production deployments use PostgreSQL. * **SQL Server** should work for all EF Core modules because Granit avoids provider-specific SQL. The `Granit.Wolverine.SqlServer` package provides the Wolverine transport for SQL Server. * **SQLite** is suitable only for local development or unit tests. It does not support concurrent migrations, and some EF Core behaviors differ (e.g., `DateTimeOffset` storage). Granit unit tests use `Microsoft.EntityFrameworkCore.InMemory`, not SQLite. ## Cache providers [Section titled “Cache providers”](#cache-providers) Granit.Caching provides a layered caching architecture. The base module registers `IDistributedCache` with an in-memory implementation. Provider packages replace or augment this. | Package | Provider | Role | | --------------------------------- | ---------------------------------------- | ---------------------------------------------- | | Granit.Caching | Memory (`MemoryDistributedCache`) | Default, no external dependency | | Granit.Caching.StackExchangeRedis | Redis (StackExchange.Redis) | Replaces `IDistributedCache` with `RedisCache` | | Granit.Caching.Hybrid | HybridCache (L1 memory + L2 distributed) | Two-tier cache with stampede protection | ### Cache encryption [Section titled “Cache encryption”](#cache-encryption) When `CachingOptions.EncryptValues` is enabled, `Granit.Caching.StackExchangeRedis` automatically applies AES-256 encryption to cached values at rest in Redis. ### Modules that consume caching [Section titled “Modules that consume caching”](#modules-that-consume-caching) The following modules use `IDistributedCache` or `IHybridCache` internally and benefit from provider upgrades: * Granit.Features (hybrid cache for feature flag resolution) * Granit.Idempotency (Redis required for distributed idempotency keys) * Granit.RateLimiting (Redis required for distributed rate limiting) ## Storage providers [Section titled “Storage providers”](#storage-providers) | Package | Provider | Status | | ------------------------------ | ----------------------------------------------------- | --------- | | Granit.BlobStorage.S3 | Any S3-compatible API (MinIO, AWS S3, Scaleway, etc.) | Supported | | Granit.BlobStorage.AzureBlob | Azure Blob Storage | Supported | | Granit.BlobStorage.GoogleCloud | Google Cloud Storage | Supported | Granit.BlobStorage uses a Direct-to-Cloud architecture with presigned URLs. The `Granit.BlobStorage.S3` package implements this via AWSSDK.S3. Any service that exposes an S3-compatible API is supported. There is no local filesystem provider. For local development, use MinIO in a container. ## Identity providers [Section titled “Identity providers”](#identity-providers) | Package | Provider | Status | | --------------------------------- | ---------------------------------------------- | ---------------------- | | Granit.Authentication.Keycloak | Keycloak | Supported | | Granit.Authentication.EntraId | Microsoft Entra ID (Azure AD) | Supported | | Granit.Authentication.Cognito | AWS Cognito | Supported | | Granit.Authentication.GoogleCloud | Google Cloud Identity Platform (Firebase Auth) | Supported | | Granit.Authentication.JwtBearer | Any OIDC/JWT issuer | Supported (base layer) | | Granit.Identity.Keycloak | Keycloak Admin API | Supported | | Granit.Identity.EntraId | Microsoft Graph API | Supported | | Granit.Identity.Cognito | AWS Cognito User Pool API | Supported | | Granit.Identity.GoogleCloud | Firebase Admin SDK | Supported | | Granit.Identity (abstractions) | Custom via `IIdentityProvider` | Implement your own | ### Authentication vs. identity [Section titled “Authentication vs. identity”](#authentication-vs-identity) * **Authentication** packages handle JWT validation and claims transformation at the HTTP middleware level. * **Identity** packages implement `IIdentityProvider` for user lookup, role management, and cache synchronization via the Admin APIs of each provider. Keycloak, Entra ID, AWS Cognito, and Google Cloud Identity Platform all have dedicated packages for both layers. ## Messaging transports [Section titled “Messaging transports”](#messaging-transports) | Package | Transport | Status | | --------------------------- | ---------------------------------------- | ---------------- | | Granit.Wolverine.Postgresql | PostgreSQL (Wolverine durable messaging) | Supported | | Granit.Wolverine.SqlServer | SQL Server (Wolverine durable messaging) | Provider package | | Granit.Wolverine | In-process (Wolverine local queues) | Supported | ### RabbitMQ [Section titled “RabbitMQ”](#rabbitmq) Wolverine natively supports RabbitMQ via the `WolverineFx.RabbitMQ` package. Granit does not provide a wrapper package for RabbitMQ because no Granit-specific configuration is needed. Add the Wolverine RabbitMQ package directly in your application if needed. ### Modules that publish messages [Section titled “Modules that publish messages”](#modules-that-publish-messages) The following modules integrate with Wolverine for asynchronous processing: * Granit.Notifications.Wolverine (notification fan-out) * Granit.Webhooks.Wolverine (webhook delivery) * Granit.DataExchange.Wolverine (import job processing) * Granit.BackgroundJobs.Wolverine (scheduled job execution) * Granit.Persistence.Migrations.Wolverine (migration progress tracking) * Granit.Privacy (GDPR erasure cascading) ## Notification channels [Section titled “Notification channels”](#notification-channels) | Package | Channel | External dependency | | ----------------------------------------------------- | ----------------------- | --------------------------------- | | Granit.Notifications.Email.Smtp | Email (SMTP) | Any SMTP server (MailKit) | | Granit.Notifications.Email.AwsSes | Email (AWS) | Amazon Simple Email Service | | Granit.Notifications.Email.AzureCommunicationServices | Email (Azure) | Azure Communication Services | | Granit.Notifications.Brevo | Email, SMS, WhatsApp | Brevo Transactional API | | Granit.Notifications.Email.SendGrid | Email (SendGrid) | SendGrid API (Twilio) | | Granit.Notifications.Email.Scaleway | Email (Scaleway) | Scaleway Transactional Email | | Granit.Notifications.Sms | SMS (abstractions) | Requires a provider (Brevo) | | Granit.Notifications.Sms.AzureCommunicationServices | SMS (Azure) | Azure Communication Services | | Granit.Notifications.WhatsApp | WhatsApp (abstractions) | Requires a provider (Brevo) | | Granit.Notifications.WebPush | Web Push | VAPID keys (Lib.Net.Http.WebPush) | | Granit.Notifications.MobilePush.GoogleFcm | Mobile Push (FCM) | Firebase Cloud Messaging | | Granit.Notifications.MobilePush.AzureNotificationHubs | Mobile Push (Azure) | Azure Notification Hubs | | Granit.Notifications.Sms.AwsSns | SMS (AWS) | Amazon SNS | | Granit.Notifications.MobilePush.AwsSns | Mobile Push (AWS) | Amazon SNS platform applications | | Granit.Notifications.Twilio | SMS, WhatsApp (Twilio) | Twilio Messaging API | | Granit.Notifications.SignalR | Real-time (WebSocket) | Redis backplane for multi-pod | ### Provider coverage [Section titled “Provider coverage”](#provider-coverage) Brevo and Twilio are multi-channel providers. Brevo implements email, SMS, and WhatsApp through a single integration. Twilio covers SMS and WhatsApp via the Twilio Messaging API, while SendGrid (a Twilio company) provides a dedicated email provider. The SMTP provider is an alternative for email-only scenarios. The SMS and WhatsApp abstraction packages define `ISmsSender` and `IWhatsAppSender` interfaces. Additional providers can be implemented against these interfaces. Azure Communication Services provides email and SMS through two dedicated packages. Azure Notification Hubs provides multi-platform mobile push. Amazon SNS provides SMS and mobile push through two dedicated packages. See also: [Cloud Providers](/reference/cloud-providers/) for all packages organized by cloud provider. ## Observability backends [Section titled “Observability backends”](#observability-backends) | Signal | Package | Export protocol | | ------- | ---------------------------------------------------- | --------------- | | Logs | Serilog + Serilog.Sinks.OpenTelemetry | OTLP | | Traces | OpenTelemetry.Instrumentation.AspNetCore/Http/EFCore | OTLP | | Metrics | OpenTelemetry | OTLP | All three signals are exported via OTLP (OpenTelemetry Protocol). The target backend is configurable at the application level. ### Tested backends [Section titled “Tested backends”](#tested-backends) | Backend | Signal | Status | | ----------------------------- | ------- | ---------------------- | | Grafana Loki | Logs | Supported (production) | | Grafana Tempo | Traces | Supported (production) | | Grafana Mimir | Metrics | Supported (production) | | Any OTLP-compatible collector | All | Compatible | Because Granit exports standard OTLP, any backend that accepts OTLP should work (e.g., Datadog, New Relic, Elastic APM, Jaeger). Only the Grafana LGTM stack is verified in production. ## Infrastructure services used by specific modules [Section titled “Infrastructure services used by specific modules”](#infrastructure-services-used-by-specific-modules) Some modules require specific infrastructure services regardless of provider choice. | Module | Requires | | ------------------------------ | ------------------------------------------- | | Granit.Vault.HashiCorp | HashiCorp Vault (VaultSharp) | | Granit.Vault.Azure | Azure Key Vault (DefaultAzureCredential) | | Granit.Vault.Aws | AWS KMS + Secrets Manager | | Granit.Vault.GoogleCloud | Google Cloud KMS + Secret Manager | | Granit.Idempotency | Redis (StackExchange.Redis) | | Granit.RateLimiting | Redis (StackExchange.Redis) | | Granit.Notifications.SignalR | Redis backplane (for multi-pod deployments) | | Granit.BlobStorage.S3 | S3-compatible object storage | | Granit.BlobStorage.AzureBlob | Azure Blob Storage | | Granit.BlobStorage.GoogleCloud | Google Cloud Storage | # Tech Stack > Third-party libraries and frameworks used by Granit, organized by domain with rationale and ADR links Granit builds on battle-tested open-source libraries. This page lists every direct production dependency, organized by functional domain. Each library was selected through an Architecture Decision Record (ADR) when multiple alternatives existed. For test-only dependencies, see [Testing stack (ADR-003)](/architecture/adr/003-testing-stack/). ## Runtime and language [Section titled “Runtime and language”](#runtime-and-language) | Component | Version | Role | | ------------ | ------- | --------------- | | .NET | 10 | Runtime and SDK | | C# | 14 | Language | | ASP.NET Core | 10 | Web framework | ## Data and persistence [Section titled “Data and persistence”](#data-and-persistence) | Library | License | Role | ADR | | --------------------------------------------------------------------------------------------------------------- | ---------- | -------------------------------------------------- | --------------------------------------- | | [Entity Framework Core](https://learn.microsoft.com/en-us/ef/core/) | MIT | ORM, migrations, interceptors (audit, soft delete) | — | | [Npgsql.EntityFrameworkCore.PostgreSQL](https://www.npgsql.org/) | PostgreSQL | PostgreSQL provider for EF Core | — | | [StackExchange.Redis](https://github.com/StackExchange/StackExchange.Redis) | MIT | Redis client for distributed caching | [ADR-002](/architecture/adr/002-redis/) | | [Microsoft.Extensions.Caching.Hybrid](https://learn.microsoft.com/en-us/aspnet/core/performance/caching/hybrid) | MIT | L1/L2 HybridCache (.NET 9+) | [ADR-002](/architecture/adr/002-redis/) | ## Messaging and scheduling [Section titled “Messaging and scheduling”](#messaging-and-scheduling) | Library | License | Role | ADR | | ---------------------------------------------- | ------- | --------------------------------------------------- | -------------------------------------------------- | | [Wolverine](https://wolverinefx.net/) | MIT | Message bus, transactional outbox, handler pipeline | [ADR-005](/architecture/adr/005-wolverine-cronos/) | | [Cronos](https://github.com/HangfireIO/Cronos) | MIT | CRON expression parsing for recurring jobs | [ADR-005](/architecture/adr/005-wolverine-cronos/) | ## Security and identity [Section titled “Security and identity”](#security-and-identity) | Library | License | Role | ADR | | ----------------------------------------------------------------------------------------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------- | --- | | [VaultSharp](https://github.com/rajanadar/VaultSharp) | Apache-2.0 | HashiCorp Vault client — used by `Granit.Vault.HashiCorp` (transit encryption, dynamic credentials) | — | | [Azure.Security.KeyVault.Keys](https://learn.microsoft.com/en-us/dotnet/api/azure.security.keyvault.keys) | MIT | Azure Key Vault key operations (encrypt/decrypt) | — | | [Azure.Security.KeyVault.Secrets](https://learn.microsoft.com/en-us/dotnet/api/azure.security.keyvault.secrets) | MIT | Azure Key Vault secret management (DB credentials) | — | | [Azure.Identity](https://learn.microsoft.com/en-us/dotnet/api/azure.identity) | MIT | DefaultAzureCredential for Azure SDK authentication | — | | [Microsoft.AspNetCore.Authentication.JwtBearer](https://learn.microsoft.com/en-us/aspnet/core/security/authentication/) | MIT | JWT Bearer authentication middleware | — | ## Validation [Section titled “Validation”](#validation) | Library | License | Role | ADR | | ------------------------------------------------------ | ---------- | ------------------------------------ | ----------------------------------------------------------- | | [FluentValidation](https://docs.fluentvalidation.net/) | Apache-2.0 | Declarative validation rules | [ADR-006](/architecture/adr/006-fluentvalidation/) | | [SmartFormat](https://github.com/axuno/SmartFormat) | MIT | Pluralization in validation messages | [ADR-008](/architecture/adr/008-smartformat-pluralization/) | ## API and web [Section titled “API and web”](#api-and-web) | Library | License | Role | ADR | | ----------------------------------------------------------------- | ------- | ------------------------------------------- | ---------------------------------------------------------- | | [Asp.Versioning](https://github.com/dotnet/aspnet-api-versioning) | MIT | API versioning (URL segment, header, query) | [ADR-004](/architecture/adr/004-asp-versioning/) | | [Scalar](https://github.com/scalar/scalar) | MIT | Interactive OpenAPI documentation UI | [ADR-009](/architecture/adr/009-scalar-api-documentation/) | ## Observability [Section titled “Observability”](#observability) | Library | License | Role | ADR | | ----------------------------------------------- | ---------- | ------------------------------------------ | ----------------------------------------------- | | [Serilog](https://serilog.net/) | Apache-2.0 | Structured logging (OTLP sink) | [ADR-001](/architecture/adr/001-observability/) | | [OpenTelemetry .NET](https://opentelemetry.io/) | Apache-2.0 | Distributed tracing, metrics (OTLP export) | [ADR-001](/architecture/adr/001-observability/) | ## Templating and document generation [Section titled “Templating and document generation”](#templating-and-document-generation) | Library | License | Role | ADR | | --------------------------------------------------- | ------------ | ---------------------------------------------- | -------------------------------------------------------------- | | [Scriban](https://github.com/scriban/scriban) | BSD-2-Clause | Template engine (Liquid-compatible, sandboxed) | [ADR-010](/architecture/adr/010-scriban-template-engine/) | | [PuppeteerSharp](https://www.puppeteersharp.com/) | MIT | HTML-to-PDF rendering via headless Chromium | [ADR-012](/architecture/adr/012-puppeteersharp-pdf-rendering/) | | [ClosedXML](https://github.com/ClosedXML/ClosedXML) | MIT | Excel (.xlsx) generation | [ADR-011](/architecture/adr/011-closedxml-excel-generation/) | ## Data exchange (import/export) [Section titled “Data exchange (import/export)”](#data-exchange-importexport) | Library | License | Role | ADR | | ---------------------------------------------------------------- | ------- | ---------------------------- | ----------------------------------------------------------- | | [Sep](https://github.com/nietras/Sep) | MIT | High-performance CSV parsing | [ADR-015](/architecture/adr/015-sep-csv-parsing/) | | [Sylvan.Data.Excel](https://github.com/MarkPflworkaround/Sylvan) | MIT | Excel (.xlsx/.xls) parsing | [ADR-016](/architecture/adr/016-sylvan-data-excel-parsing/) | ## Storage and imaging [Section titled “Storage and imaging”](#storage-and-imaging) | Library | License | Role | ADR | | ---------------------------------------------------- | ---------- | ---------------------------------------------------- | ------------------------------------------------------------ | | [AWSSDK.S3](https://aws.amazon.com/sdk-for-net/) | Apache-2.0 | S3-compatible object storage (MinIO, Ceph, etc.) | — | | [Magick.NET](https://github.com/dlemstra/Magick.NET) | Apache-2.0 | Image processing (resize, WebP/AVIF, EXIF stripping) | [ADR-013](/architecture/adr/013-magicknet-image-processing/) | ## Notifications [Section titled “Notifications”](#notifications) | Library | License | Role | ADR | | ------------------------------------------------------------------------------------------------------------------------ | ------- | ------------------------------------------ | --- | | [MailKit](https://github.com/jstedfast/MailKit) | MIT | SMTP email delivery | — | | [Azure.Communication.Email](https://learn.microsoft.com/en-us/azure/communication-services/quickstarts/email/send-email) | MIT | Azure Communication Services email sending | — | | [Azure.Communication.Sms](https://learn.microsoft.com/en-us/azure/communication-services/quickstarts/sms/send) | MIT | Azure Communication Services SMS sending | — | | [Microsoft.Azure.NotificationHubs](https://learn.microsoft.com/en-us/azure/notification-hubs/) | MIT | Azure Notification Hubs push notifications | — | | [Microsoft.AspNetCore.SignalR](https://learn.microsoft.com/en-us/aspnet/core/signalr/) | MIT | Real-time WebSocket notifications | — | | [Lib.Net.Http.WebPush](https://github.com/nicoriff/Lib.Net.Http.WebPush) | MIT | Web Push notifications (VAPID) | — | ## Miscellaneous [Section titled “Miscellaneous”](#miscellaneous) | Library | License | Role | ADR | | ------------------------------------------------------------------------------------------------------- | ------- | ------------------------------------------- | --- | | [Microsoft.IO.RecyclableMemoryStream](https://github.com/microsoft/Microsoft.IO.RecyclableMemoryStream) | MIT | Pooled memory streams (reduces GC pressure) | — | ## Test-only dependencies [Section titled “Test-only dependencies”](#test-only-dependencies) These libraries are used exclusively in `*.Tests` projects and are not shipped in production packages. | Library | License | Role | ADR | | ---------------------------------------------------- | ------------ | ------------------------------ | ----------------------------------------------------------------------------------------------------- | | [xUnit v3](https://xunit.net/) | Apache-2.0 | Test framework | [ADR-003](/architecture/adr/003-testing-stack/) | | [Shouldly](https://docs.shouldly.org/) | BSD-3-Clause | Assertion library | [ADR-003](/architecture/adr/003-testing-stack/), [ADR-014](/architecture/adr/014-migration-shouldly/) | | [NSubstitute](https://nsubstitute.github.io/) | BSD-3-Clause | Mocking framework | [ADR-003](/architecture/adr/003-testing-stack/) | | [Bogus](https://github.com/bchavez/Bogus) | MIT | Test data generation | [ADR-003](/architecture/adr/003-testing-stack/) | | [Testcontainers](https://dotnet.testcontainers.org/) | MIT | Docker-based integration tests | [ADR-007](/architecture/adr/007-testcontainers/) | ## License summary [Section titled “License summary”](#license-summary) | License | Count | Examples | | ------------ | ----- | ---------------------------------------------------- | | MIT | 49 | EF Core, Wolverine, ClosedXML, StackExchange.Redis | | Apache-2.0 | 15 | OpenTelemetry, Serilog, FluentValidation, Magick.NET | | BSD-3-Clause | 2 | NSubstitute, Shouldly | | BSD-2-Clause | 1 | Scriban | | PostgreSQL | 1 | Npgsql | All dependencies are OSI-approved open-source licenses compatible with Apache-2.0. The full list with versions and copyright notices is maintained in [`THIRD-PARTY-NOTICES.md`](https://github.com/granit-fx/granit-dotnet/blob/develop/THIRD-PARTY-NOTICES.md). # Troubleshooting > Common errors, anti-patterns, and frequently asked questions for Granit This section covers the most common issues encountered when building with Granit, patterns to avoid, and answers to frequently asked questions. ## Section contents [Section titled “Section contents”](#section-contents) * [Common Errors](./common-errors/) — module loading failures, DbContext misconfiguration, validator registration, multi-tenancy, authentication, and caching issues with solutions * [Anti-patterns](./anti-patterns/) — code patterns that compile but cause runtime issues, performance problems, or violate Granit conventions * [FAQ](./faq/) — answers to the most frequently asked questions about Granit’s architecture, requirements, and capabilities ## Getting help [Section titled “Getting help”](#getting-help) If your issue is not covered here: 1. Search existing [GitHub issues](https://github.com/granit-fx/granit-dotnet/issues) 2. Check the [Reference documentation](/reference/) for the specific module 3. Open a new issue using the **Bug** template with reproduction steps # Anti-patterns > Common code patterns that compile but cause runtime issues, break conventions, or produce maintenance debt These patterns compile without errors but cause problems at runtime, fail Granit analyzers, or create maintenance debt. Each entry shows the anti-pattern and its correct alternative. ## Using `DateTime.Now` or `DateTime.UtcNow` [Section titled “Using DateTime.Now or DateTime.UtcNow”](#using-datetimenow-or-datetimeutcnow) **Why it is a problem**: Direct calls to `DateTime.Now` are untestable (you cannot control time in tests) and ignore the application’s configured timezone. Granit provides `TimeProvider` and `IClock` for this purpose. ```csharp // Anti-pattern var now = DateTime.UtcNow; entity.CreatedAt = DateTime.Now; // Correct -- inject TimeProvider (preferred in .NET 10) public class MyService(TimeProvider timeProvider) { public void DoWork() { var now = timeProvider.GetUtcNow(); } } // Correct -- inject IClock (Granit abstraction) public class MyService(IClock clock) { public void DoWork() { var now = clock.Now; } } ``` ## String interpolation in log calls [Section titled “String interpolation in log calls”](#string-interpolation-in-log-calls) **Why it is a problem**: String interpolation allocates a string on every call, even when the log level is disabled. Granit requires `[LoggerMessage]` source-generated logging for zero-allocation structured logs. ```csharp // Anti-pattern -- allocates on every call logger.LogInformation($"Processing order {orderId} for tenant {tenantId}"); // Anti-pattern -- message template is fine, but still not source-generated logger.LogInformation("Processing order {OrderId} for tenant {TenantId}", orderId, tenantId); // Correct -- source-generated, zero-allocation when level is disabled [LoggerMessage(Level = LogLevel.Information, Message = "Processing order {OrderId} for tenant {TenantId}")] private static partial void LogProcessingOrder( ILogger logger, Guid orderId, Guid tenantId); ``` ## `new Regex()` instead of `[GeneratedRegex]` [Section titled “new Regex() instead of \[GeneratedRegex\]”](#new-regex-instead-of-generatedregex) **Why it is a problem**: `new Regex(..., RegexOptions.Compiled)` compiles the pattern at runtime using reflection emit. `[GeneratedRegex]` produces the same optimized code at compile time with no runtime cost. On user input, always set a timeout to prevent ReDoS attacks. ```csharp // Anti-pattern private static readonly Regex EmailPattern = new(@"^[^@\s]+@[^@\s]+\.[^@\s]+$", RegexOptions.Compiled); // Correct [GeneratedRegex(@"^[^@\s]+@[^@\s]+\.[^@\s]+$", RegexOptions.None, matchTimeoutMilliseconds: 1000)] private static partial Regex EmailPattern(); ``` ## Returning EF Core entities from endpoints [Section titled “Returning EF Core entities from endpoints”](#returning-ef-core-entities-from-endpoints) **Why it is a problem**: Returning entities directly couples your API contract to your database schema, leaks navigation properties, and prevents independent evolution of the API surface. Granit requires `*Response` records for all endpoint return values. ```csharp // Anti-pattern -- leaks entity structure, navigation properties, shadow properties app.MapGet("/products/{id}", async (Guid id, MyDbContext db) => await db.Products.FindAsync(id)); // Correct -- map to a response record app.MapGet("/products/{id}", async (Guid id, MyDbContext db) => { var product = await db.Products.FindAsync(id); return product is null ? Results.NotFound() : Results.Ok(new ProductResponse(product.Id, product.Name, product.Price)); }); public record ProductResponse(Guid Id, string Name, decimal Price); ``` ## Using `TypedResults.BadRequest()` for errors [Section titled “Using TypedResults.BadRequest\() for errors”](#using-typedresultsbadrequeststring-for-errors) **Why it is a problem**: Returning a plain string body does not conform to RFC 7807 Problem Details. Clients that parse `application/problem+json` will not understand the response. Granit standardizes on `TypedResults.Problem()`. ```csharp // Anti-pattern return TypedResults.BadRequest("Product name is required"); // Correct -- RFC 7807 Problem Details return TypedResults.Problem( detail: "Product name is required", statusCode: StatusCodes.Status400BadRequest); ``` ## Manual `HasQueryFilter` for standard interfaces [Section titled “Manual HasQueryFilter for standard interfaces”](#manual-hasqueryfilter-for-standard-interfaces) **Why it is a problem**: `ApplyGranitConventions` already registers query filters for `ISoftDeletable`, `IMultiTenant`, `IActive`, `IProcessingRestrictable`, and `IPublishable`. Adding manual filters causes duplicates (EF Core merges them with AND, potentially double-filtering) or conflicts when the convention filter uses a different expression. ```csharp // Anti-pattern -- conflicts with ApplyGranitConventions public class ProductConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) { builder.HasQueryFilter(p => !p.IsDeleted); // duplicate filter } } // Correct -- let ApplyGranitConventions handle it public class ProductConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) { builder.ToTable("Products"); builder.HasIndex(p => p.Name); // No HasQueryFilter -- handled by ApplyGranitConventions } } ``` ## `BuildServiceProvider()` in configuration [Section titled “BuildServiceProvider() in configuration”](#buildserviceprovider-in-configuration) **Why it is a problem**: Calling `BuildServiceProvider()` inside a service registration lambda creates a second DI container. Services resolved from it are different instances than those in the real container, breaking singleton guarantees and causing subtle bugs. The compiler emits `ASP0000` for this. ```csharp // Anti-pattern -- creates a second container services.AddSingleton(sp => { var provider = services.BuildServiceProvider(); // ASP0000 var config = provider.GetRequiredService(); return new MyService(config); }); // Correct -- use the service provider passed to the factory services.AddSingleton(sp => { var config = sp.GetRequiredService(); return new MyService(config); }); ``` ## Merging Reader/Writer interfaces [Section titled “Merging Reader/Writer interfaces”](#merging-readerwriter-interfaces) **Why it is a problem**: Granit follows CQRS (Command Query Responsibility Segregation). `I*Reader` and `I*Writer` interfaces are intentionally separate to allow independent scaling, caching of read paths, and clear separation of concerns. Combining them into a single `I*Store` interface in constructors defeats this design. ```csharp // Anti-pattern -- merging read and write concerns public class BlobService(IBlobDescriptorStore store) { // store.GetAsync(...) and store.SaveAsync(...) in the same dependency } // Correct -- separate reader and writer public class BlobService( IBlobDescriptorReader reader, IBlobDescriptorWriter writer) { // Queries go through reader, commands go through writer } ``` ## Using `Dto` suffix on request/response types [Section titled “Using Dto suffix on request/response types”](#using-dto-suffix-on-requestresponse-types) **Why it is a problem**: Granit uses `Request` for input bodies and `Response` for return types. The `Dto` suffix is ambiguous and does not indicate direction. Additionally, module-specific DTOs must be prefixed with the module context to avoid OpenAPI schema conflicts. ```csharp // Anti-pattern public record ProductDto(Guid Id, string Name); public record CreateProductDto(string Name, decimal Price); // Correct public record ProductResponse(Guid Id, string Name); public record CreateProductRequest(string Name, decimal Price); // For module-specific types, add the module prefix public record WorkflowTransitionRequest(string TargetState); // NOT: TransitionRequest (too generic, causes OpenAPI conflicts) ``` # Common Errors > Frequently encountered errors when building with Granit and how to fix them ## Module loading failures [Section titled “Module loading failures”](#module-loading-failures) ### Missing `[DependsOn]` attribute [Section titled “Missing \[DependsOn\] attribute”](#missing-dependson-attribute) **Symptom**: `InvalidOperationException` at startup — a service cannot be resolved because the module that registers it was never loaded. ```text System.InvalidOperationException: Unable to resolve service for type 'Granit.Caching.IDistributedCacheManager' while attempting to activate 'MyApp.Services.ProductService'. ``` **Cause**: Your module uses a Granit service but does not declare the dependency. **Fix**: Add the `[DependsOn]` attribute to your module class. ```csharp // Before -- missing dependency public sealed class MyAppModule : GranitModule { } // After [DependsOn(typeof(GranitCachingModule))] public sealed class MyAppModule : GranitModule { } ``` ### Circular dependencies [Section titled “Circular dependencies”](#circular-dependencies) **Symptom**: `InvalidOperationException` at startup listing the modules involved in the cycle. ```text System.InvalidOperationException: Circular dependency detected: ModuleA -> ModuleB -> ModuleC -> ModuleA ``` **Fix**: Break the cycle by extracting shared abstractions into a separate package. The dependent modules reference the abstractions package instead of each other. ## DbContext configuration issues [Section titled “DbContext configuration issues”](#dbcontext-configuration-issues) ### Missing `ApplyGranitConventions` [Section titled “Missing ApplyGranitConventions”](#missing-applygranitconventions) **Symptom**: Soft-deleted entities still appear in queries. Multi-tenant data leaks across tenants. `IActive` and `IPublishable` filters do not apply. **Cause**: The isolated `DbContext` does not call `ApplyGranitConventions` in `OnModelCreating`. **Fix**: Call `modelBuilder.ApplyGranitConventions(currentTenant, dataFilter)` at the end of `OnModelCreating`. ```csharp public class MyDbContext( DbContextOptions options, ICurrentTenant? currentTenant = null, IDataFilter? dataFilter = null) : DbContext(options) { protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); modelBuilder.ApplyConfigurationsFromAssembly(typeof(MyDbContext).Assembly); // This line is mandatory modelBuilder.ApplyGranitConventions(currentTenant, dataFilter); } } ``` Caution Never add manual `HasQueryFilter` calls for `ISoftDeletable`, `IMultiTenant`, `IActive`, or `IPublishable`. `ApplyGranitConventions` handles all standard filters centrally. Manual filters cause duplicates or conflicts. ### Missing interceptors [Section titled “Missing interceptors”](#missing-interceptors) **Symptom**: `CreatedAt`, `CreatedBy`, `LastModifiedAt`, `LastModifiedBy` fields are never populated. Soft-deleted entities are hard-deleted instead of flagged. **Cause**: The `AddDbContextFactory` call does not resolve `AuditedEntityInterceptor` and `SoftDeleteInterceptor`. **Fix**: Use the `(sp, options)` overload to resolve interceptors from the service provider. ```csharp services.AddDbContextFactory((sp, options) => { options.UseNpgsql(connectionString); options.AddInterceptors( sp.GetRequiredService(), sp.GetRequiredService()); }, ServiceLifetime.Scoped); ``` ## Validator not executing [Section titled “Validator not executing”](#validator-not-executing) ### Missing registration [Section titled “Missing registration”](#missing-registration) **Symptom**: Invalid input passes through endpoints without triggering `FluentValidation` rules. No validation errors are returned. **Cause**: Validators are not registered in the DI container. `FluentValidationEndpointFilter` silently skips validation when no validator is found for the request type. **Fix**: The registration method depends on whether the module uses Wolverine. **With Wolverine** (module has `[assembly: WolverineHandlerModule]`): Validators are discovered automatically by `AddGranitWolverine()`. No manual registration needed. **Without Wolverine**: Call `AddGranitValidatorsFromAssemblyContaining()` manually in your module’s `ConfigureServices`. ```csharp public override void ConfigureServices(ServiceConfigurationContext context) { context.Services .AddGranitValidatorsFromAssemblyContaining(); } ``` ### Validator in the wrong assembly [Section titled “Validator in the wrong assembly”](#validator-in-the-wrong-assembly) **Symptom**: `AddGranitValidatorsFromAssemblyContaining()` was called, but some validators are still not running. **Cause**: The type `T` used as the assembly marker is in a different assembly from the missing validators. **Fix**: Verify that the validator class lives in the same assembly as the marker type. If validators are spread across multiple assemblies, call the registration method once per assembly. ## Multi-tenancy errors [Section titled “Multi-tenancy errors”](#multi-tenancy-errors) ### `TenantId` is null [Section titled “TenantId is null”](#tenantid-is-null) **Symptom**: `DbUpdateException` when inserting entities that implement `IMultiTenant` — the `TenantId` column has a NOT NULL constraint but the value is null. **Cause**: The `ICurrentTenant` context was not set before the operation. This typically happens in background jobs or message handlers where the tenant context is not propagated automatically. **Fix**: Wrap the operation in a tenant scope. ```csharp using (currentTenant.Change(tenantId)) { await dbContext.Products.AddAsync(product, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); } ``` ### `IsAvailable` not checked [Section titled “IsAvailable not checked”](#isavailable-not-checked) **Symptom**: `InvalidOperationException` when accessing `ICurrentTenant.Id` — the tenant context is not available. **Cause**: Code accesses `Id` directly without checking `IsAvailable` first. When multi-tenancy is not installed, a `NullTenantContext` (`IsAvailable = false`) is registered by default. **Fix**: Always check `IsAvailable` before accessing `Id`. ```csharp if (currentTenant.IsAvailable) { var tenantId = currentTenant.Id; // tenant-specific logic } ``` ## Authentication issues [Section titled “Authentication issues”](#authentication-issues) ### JWT token rejected [Section titled “JWT token rejected”](#jwt-token-rejected) **Symptom**: All authenticated requests return `401 Unauthorized` even with a valid token. **Common causes**: 1. **Wrong authority URL**: The `Authority` in `JwtBearerOptions` does not match the `iss` claim in the token. 2. **Clock skew**: The server clock is ahead of the token issuer. Default `ClockSkew` is 5 minutes. 3. **Audience mismatch**: The `ValidAudience` does not match the `aud` claim. **Diagnosis**: Decode the token at [jwt.io](https://jwt.io) and compare the `iss`, `aud`, and `exp` claims against your configuration. ### Keycloak claims not mapped [Section titled “Keycloak claims not mapped”](#keycloak-claims-not-mapped) **Symptom**: `ICurrentUserService` returns null or empty values for user properties (name, email, roles) even though the token contains them. **Cause**: Keycloak uses non-standard claim names by default (e.g., `preferred_username` instead of `name`, `realm_access.roles` instead of `role`). **Fix**: Ensure `GranitAuthenticationKeycloakModule` is included in your dependency chain. It registers the `KeycloakClaimsTransformation` that maps Keycloak-specific claims to standard ClaimTypes. ```csharp [DependsOn(typeof(GranitAuthenticationKeycloakModule))] public sealed class MyAppModule : GranitModule { } ``` ## Caching issues [Section titled “Caching issues”](#caching-issues) ### Redis connection refused [Section titled “Redis connection refused”](#redis-connection-refused) **Symptom**: `RedisConnectionException` at startup or on the first cache operation. ```text StackExchange.Redis.RedisConnectionException: It was not possible to connect to the redis server(s). UnableToConnect on localhost:6379/Interactive ``` **Common causes**: 1. Redis is not running or not reachable at the configured address 2. TLS is required but not configured 3. Password authentication is required but not provided **Diagnosis**: Test connectivity with `redis-cli ping` from the application host. Check the connection string in your configuration. ### HybridCache not invalidating [Section titled “HybridCache not invalidating”](#hybridcache-not-invalidating) **Symptom**: Stale data is served from cache after updates. **Common causes**: 1. **Missing cache key invalidation**: After a write operation, the corresponding cache entry was not removed or updated. 2. **L1/L2 desynchronization**: The in-memory (L1) cache on one instance is not aware of invalidation performed by another instance. HybridCache handles this via Redis pub/sub, but only if the Redis backplane is correctly configured. **Fix**: Verify that `AddGranitHybridCache()` is called with a valid Redis connection and that the Redis instance supports pub/sub (not all managed Redis services enable it by default). # FAQ > Frequently asked questions about Granit's architecture, requirements, and capabilities ## Do I need Wolverine to use Granit? [Section titled “Do I need Wolverine to use Granit?”](#do-i-need-wolverine-to-use-granit) No. Wolverine is an optional dependency. Granit’s module system, persistence, caching, authentication, and most other features work without Wolverine. When Wolverine is not installed, Granit provides channel fallbacks for features that would otherwise use messaging (e.g., notifications, background jobs). You lose transactional outbox and durable message handling, but the core functionality remains available. See [Wolverine Optionality](/concepts/wolverine-optionality/) for the full details on what changes with and without Wolverine. ## Which database is supported? [Section titled “Which database is supported?”](#which-database-is-supported) **PostgreSQL** is the primary and fully tested database. All Granit `*.EntityFrameworkCore` packages are tested against PostgreSQL. **SQL Server** and **SQLite** are possible through EF Core’s provider abstraction, but they are not part of the automated test suite. If you use them: * Sequential GUID generation (`Granit.Guids`) is optimized for PostgreSQL clustered indexes. SQL Server uses a different byte-ordering for `NEWSEQUENTIALID()`. * Some migration scripts may use PostgreSQL-specific SQL (e.g., `jsonb` columns). * The Wolverine transactional outbox (`Granit.Wolverine.Postgresql`) is PostgreSQL-only by design. ## Can I use Granit for microservices? [Section titled “Can I use Granit for microservices?”](#can-i-use-granit-for-microservices) Yes. Granit is designed for both modular monolith and microservice architectures. Each Granit module is a self-contained unit with its own isolated `DbContext`, service registrations, and API endpoints. To extract a module into a standalone service: 1. Create a new ASP.NET Core host project 2. Reference only the Granit modules that service needs 3. Configure the module’s `DbContext` to point at a separate database 4. Use Wolverine messaging (or HTTP) for inter-service communication See [Modular Monolith vs Microservices](/concepts/modular-monolith-vs-microservices/) for architecture guidance. ## What is the minimum .NET version? [Section titled “What is the minimum .NET version?”](#what-is-the-minimum-net-version) **NET 10** is the minimum supported version. Granit uses C# 14 language features and .NET 10 APIs (`TimeProvider`, `HybridCache`, `[GeneratedRegex]` improvements, etc.) that are not available in earlier runtimes. There are no plans to backport to .NET 8 or .NET 9. ## How do I add a custom module? [Section titled “How do I add a custom module?”](#how-do-i-add-a-custom-module) A Granit module is a class library with a single `GranitModule` subclass. The minimum structure: ```text src/MyCompany.MyModule/ MyCompanyMyModuleModule.cs <-- GranitModule subclass MyCompanyMyModuleModule.csproj Extensions/ ServiceCollectionExtensions.cs <-- AddMyModule() method ``` The module class declares dependencies and registers services: ```csharp [DependsOn(typeof(GranitCoreModule))] public sealed class MyCompanyMyModuleModule : GranitModule { public override void ConfigureServices(ServiceConfigurationContext context) { context.Services.AddMyModule(); } } ``` See [Module Structure](/contributing/module-structure/) for the full guide, including EF Core integration, endpoint mapping, and testing conventions. ## How does multi-tenancy work? [Section titled “How does multi-tenancy work?”](#how-does-multi-tenancy-work) `ICurrentTenant` is available in every module through `Granit.Core.MultiTenancy` without referencing `Granit.MultiTenancy`. A `NullTenantContext` (`IsAvailable = false`) is registered by default. When `Granit.MultiTenancy` is installed, it provides the actual tenant resolution (from headers, claims, route values, etc.) and populates the tenant context. Entities implement `IMultiTenant` with a `Guid? TenantId` property. `ApplyGranitConventions` adds a global query filter that automatically scopes queries to the current tenant. ## How does localization work? [Section titled “How does localization work?”](#how-does-localization-work) Granit supports **17 cultures**: 14 base languages (en, fr, nl, de, es, it, pt, zh, ja, pl, tr, ko, sv, cs) plus 3 regional variants (fr-CA, en-GB, pt-BR). Every module that uses localization must provide JSON resource files for all 17 cultures. Regional variant files only contain keys that differ from the base language (`en.json` covers both `en-US` and `en` — no `en-US.json` is needed). `Granit.Localization.SourceGenerator` generates strongly-typed key accessors from the JSON files at compile time. ## How do I run only the tests for a specific package? [Section titled “How do I run only the tests for a specific package?”](#how-do-i-run-only-the-tests-for-a-specific-package) Use the `--filter` option with the test project name: ```bash dotnet test --filter "FullyQualifiedName~Granit.Caching.Tests" ``` Or run the specific test project directly: ```bash dotnet test tests/Granit.Caching.Tests/Granit.Caching.Tests.csproj ``` ## What compliance standards does Granit support? [Section titled “What compliance standards does Granit support?”](#what-compliance-standards-does-granit-support) Granit is designed with **GDPR** and **ISO 27001** compliance built into its architecture: * **GDPR**: Data minimization, right to erasure (`ISoftDeletable`, `IProcessingRestrictable`), pseudonymization via `Granit.Privacy`, encryption at rest via `Granit.Vault` * **ISO 27001**: Audit trails (`AuditedEntityInterceptor`, `Granit.Timeline`), encryption in transit and at rest, secret management via HashiCorp Vault See [Compliance](/concepts/compliance/) for detailed mapping of Granit features to compliance requirements.