9 decisions · architect's record

Architectural decisions

Capturing the key decisions made along the way to formulate a best-of-breed code footprint.

AdoptedFoundational (codebase inception)

Modular monolith over microservices

The most-shipped pattern for serious B2B SaaS in the 5-50 engineer range is a modular monolith — one deployable, well-bounded internal modules, an event log for the patterns that benefit from async. Microservices solve organizational scaling problems most CleenUI customers don't have; they pay the latency, ops, and debugging tax for capabilities they don't need.

CleenUI ships as 14 vertical-slice modules (M01-M14) inside a single ASP.NET Core 8 deployable. Cross-module communication is in-process. Background work runs as Azure WebJobs + Azure Functions, organized as a 12-project Visual Studio solution.

Positive

  • Sub-millisecond cross-module calls. No service-mesh overhead.
  • One database, one schema, ACID across the application.
  • Single deployment pipeline. One staging environment, one production environment, no inter-version compatibility matrix.
  • Easy to migrate to microservices later (per-module boundaries are real) — but you don't pay the cost until you need to.

Trade-offs

  • Vertical scaling is the primary scale-out story. Horizontal scaling works (the API is stateless) but the database is the throughput ceiling.
  • A bad release can take everything down. The mitigation: rigorous CI + canary deploys, not microservices.
  • Microservices (one service per module). Rejected: operational complexity not justified by team size.
  • Single 'big-ball-of-mud' monolith (no module boundaries). Rejected: that's exactly what the codebase is trying to NOT be.
AdoptedFoundational

Stored-procedure-first data access — no Entity Framework

Most .NET shops reach for EF Core by reflex. CleenUI predates EF being a serious option for the workload (writes were already in flight when EF Code First matured) and the team made an explicit choice to not adopt it once EF became viable. Stored procedures give you: query plans the DBA can read and tune, parameter-sniffing control, server-side transaction logic, security boundary at the procedure level (apps can't write ad-hoc SQL), and zero ORM-impedance-mismatch for complex projections.

All reads and writes go through 700+ hand-tuned stored procedures via Dapper + ADO.NET. Migrations are scripted T-SQL files checked into the repo. POCOs are mapped by Dapper. No DbContext, no IQueryable, no LINQ-to-SQL.

Positive

  • Predictable query plans. The DBA can see exactly what runs and tune it.
  • Schema changes ship with deliberate migration scripts, not auto-generated migrations that surprise you in production.
  • Stored procedures are a security boundary — least-privilege at the proc level means the app role can't execute arbitrary SQL.
  • Complex projections and reporting queries don't fight an ORM. Joins involving 6+ tables are normal in this domain.

Trade-offs

  • More boilerplate per CRUD operation than EF.
  • Stored procedures are a skill not every .NET dev has anymore. Mitigated by extensive examples and the architect's onboarding involvement.
  • Entity Framework Core 8. Rejected: query plans are opaque, migrations are surprising, and complex projections fight the ORM.
  • Dapper without stored procedures (inline SQL in C#). Rejected: loses the security and tuning benefits.
AdoptedFoundational

Single React 18 frontend — not Blazor, not Angular

Microsoft pushed Blazor hard for .NET teams. It's a reasonable choice for shops that need end-to-end C#, but the React ecosystem dwarfs Blazor in component libraries, hiring pool, and third-party integration coverage. For B2B apps that need real visual polish + accessibility + a large component surface, React wins on selection alone.

Single React 18 + Vite frontend. 61 components in 12 categories. No Blazor, no Angular, no MAUI. CleenUI is a TypeScript-ready JSX codebase; the typings ship with the component library.

Positive

  • Hiring is easy — React developers are abundant and cheap relative to specialized Blazor talent.
  • Component-library ecosystem (npm) gives an escape hatch for anything CleenUI doesn't ship.
  • Bundle sizes are reasonable (~150-200 KB initial gzipped) compared to Blazor WASM's ~1.5-3 MB.

Trade-offs

  • .NET teams already fluent in C# pay a React learning tax.
  • Two languages in the codebase — C# on the server, TypeScript-ready JSX on the client.
  • Blazor Server / Blazor WASM. Rejected: smaller ecosystem, larger bundles, harder hiring.
  • Angular. Rejected: heavier framework, smaller hiring pool than React.
  • Multiple frontends (one per surface). Rejected: maintenance multiplier.
AdoptedFoundational

Auth0 as the identity provider

Identity is a category where 'build it yourself' is almost always wrong. The threats evolve faster than internal teams can keep up with (MFA bypass, credential stuffing, token replay), and the compliance surface (SAML, SOC 2, etc.) is significant. CleenUI customers ship multi-tenant B2B apps where identity is a hard requirement on day one.

Auth0 + JWT is the default identity provider. OIDC, social-login providers (Google, Microsoft, GitHub, Apple), MFA, and password-reset flows are wired end-to-end. The Auth0 management API is also used for organization-tenancy provisioning when needed.

Positive

  • Day-one MFA, social login, and credential stuffing protection without rolling your own.
  • SOC 2 + ISO 27001 inheritance from the IdP simplifies the customer's compliance story.
  • Standard OIDC contract — easy to swap providers (Microsoft Entra, Okta, Keycloak) if a customer requires it.

Trade-offs

  • Per-MAU pricing scales with user count. Mitigated by the standard-OIDC swappability — large customers can self-host Keycloak if needed.
  • Vendor lock-in risk on the management-API surface. Mitigated by keeping the management calls behind a thin abstraction.
  • ASP.NET Core Identity. Rejected: rolling our own MFA and social-login was not worth the time.
  • Microsoft Entra (Azure AD B2C). Rejected: less flexible per-tenant configuration than Auth0.
  • Keycloak (self-hosted). Considered for high-volume customers — kept as a swap-in option.
AdoptedFoundational

Row-level access control at the database layer

Multi-tenant data isolation is the most common source of catastrophic data leaks in B2B SaaS. App-layer filters ('WHERE account_id = @currentAccount') are the standard pattern but fail catastrophically when a developer forgets the filter on one query. The CleenUI domain (B2B SaaS, audit-heavy, regulated industries) cannot afford that failure mode.

Every queryable table has an account-scoping column. The data layer enforces account scoping at the stored-procedure entry points — the application role cannot execute ad-hoc SQL bypassing it. Cross-account queries require a specific elevated role (admin or impersonation context).

Positive

  • A forgotten filter in the application code can't leak cross-tenant data — the proc still scopes it.
  • Audit trails capture every cross-tenant access via the impersonation table.
  • Compliance reviewers see a single point where multi-tenancy is enforced.

Trade-offs

  • Every new query must respect the contract. Mitigated by the proc-only access model.
  • Cross-tenant reporting (rare, but needed for ops dashboards) requires an explicit elevation path.
  • SQL Server row-level security (RLS) policies. Considered — kept as a complementary layer for the most sensitive tables.
  • Per-tenant database. Rejected: 14 modules × N tenants × backup-and-migration overhead doesn't scale operationally.
AdoptedFoundational

Dynamic translation registry — per-entity translatable columns at runtime

i18n is usually done with .resx files (static keys translated at build time) or string-table tables (one row per key, language column). Both fail when content itself is user-editable — a customer renames a category, that rename needs to translate to 30 languages without a code change.

Every translatable entity has a sibling `<Entity>Language` table with per-language overrides. A translation registry tracks which entities have pending translations, dispatches them to the configured machine-translation provider, and caches results. Adding a new language is a configuration row, not a schema change. Adding a new translatable column on an existing entity is a one-line registry update.

Positive

  • User-editable content translates automatically without code changes.
  • Adding a new supported language is configuration, not code.
  • 100+ languages supported out of the box, each with their own RTL/LTR + collation rules.

Trade-offs

  • Schema is larger — hundreds of *Language sibling tables.
  • Translation backfill is a background job that takes hours when adding a new language to a large account.
  • Static .resx files. Rejected: doesn't handle user-editable content.
  • Single string-table with composite keys. Rejected: lookup hot path is much slower than indexed sibling tables.
AdoptedFoundational

Two-layer caching — in-memory + Redis

Cache invalidation is one of the two hard things. A single-layer cache (in-memory only) can't share across API instances. A single-layer distributed cache (Redis only) pays a network round-trip on every read. The hot path is reads of slow-to-compute aggregates; the right answer is a small in-process cache backed by a shared distributed cache.

Layer 1 is Microsoft.Extensions.Caching.Memory per process (sub-ms reads). Layer 2 is Azure Redis Cache shared across all API instances + WebJobs. Invalidation is wired into the data-access wrappers around every write stored procedure — on a write, the wrapper invalidates both layers for the affected keys. Cache keys are prefixed with `accountId` for per-tenant isolation.

Positive

  • Sub-ms reads on hot paths.
  • Cache-coherence across API instances via the L2 distributed cache.
  • Per-tenant cache isolation — cross-tenant leaks are structurally impossible.
  • Invalidation is automatic — developers can't forget to invalidate after a write.

Trade-offs

  • Two caches mean two failure modes. Redis outage falls through to L1, then to the DB — graceful degradation, but visible latency.
  • Cache-key naming convention is enforced by code review and a unit test — there's no compiler help.
  • In-memory cache only. Rejected: doesn't share across instances.
  • Redis only. Rejected: network round-trip on every hot-path read.
AdoptedFoundational

Azure as the default host — portable to AWS / on-prem

Microsoft + .NET shops default to Azure; AWS and on-prem are common for regulated customers. The codebase needs to make Azure the cheapest path to a deploy, while not locking out customers who need to run elsewhere.

Default hosting targets are Azure App Service / Container Apps (API), Azure SQL Database (DB), Azure Functions + WebJobs (background work), and Azure Redis Cache. Every Azure-specific binding (Storage Queues, Service Bus, Blob storage, App Insights) lives behind a thin abstraction so the deployment target can swap to AWS SQS / S3 / CloudWatch / RDS without touching domain code.

Positive

  • Single-click deploy to Azure from the architect-led starting point.
  • Codebase has been deployed to AWS (EKS + RDS) and on-prem SQL Server in past engagements without domain-code changes.
  • OpenTelemetry-compatible — observability can swap to Datadog, New Relic, or any OTLP collector.

Trade-offs

  • Abstractions add a thin layer of indirection. The cost is acceptable; the benefit is real portability.
  • AWS as the default. Rejected: most CleenUI customers are .NET-first / Microsoft-first, and Azure-default reduces friction.
  • Cloud-agnostic from day one (no Azure-specific bindings). Rejected: cost of abstraction without commensurate benefit for the common case.
AdoptedStrategic

Architect-led engagement model, not self-serve license

A licensed codebase of CleenUI's depth (524 endpoints, 300+ tables, 14 modules, custom integrations) is not something a team adopts cold. Either the architect is involved at the kickoff or the team spends 60+ hours rediscovering the architecture by reading code. Neither is the customer's preferred outcome.

Every CleenUI engagement starts with a no-cost 30-minute architecture review. Pricing is custom-scoped per engagement, not a fixed-price license. The architect (Shawn Livermore) is the named delivery contact and remains involved through go-live.

Positive

  • Customers get to working software in weeks, not quarters.
  • Architect's direct involvement catches misuse patterns early.
  • Pricing matches the actual scope — small projects pay less, large projects pay more, neither overpays.

Trade-offs

  • Doesn't scale to thousands of customers per architect-year — by design. CleenUI is a high-touch, mid-volume product.
  • Requires the architect's calendar to be the bottleneck. Mitigated by qualifying prospects via the /book-call funnel.
  • Self-serve license + community support. Rejected: doesn't match the codebase depth.
  • SaaS subscription. Rejected: codebase is the product, not a hosted service.
Get started

Get started with CleenUI.

Two paths to your first component. Pick the one that fits how your team builds.

Path A · Recommended

With AI agent skills

One prompt to your AI tool. The Setup skill handles dependencies, design tokens, build config, and component registration — all without leaving your editor.

Path B · Manual

With npm

The classic flow. Install the package, import the styles, drop in your first component. No agents required — same end result.