Skip to content

ShopFloor - Architecture

How the code is organized, how it’s wired up at runtime, and how requests flow through it. For a higher-level system view see 01-system-overview.md. For the data shape see 11-data-model.md.

⚠️ Assumption markers indicate items not fully verified — flag corrections welcome.


The solution applies Clean Architecture with strict unidirectional dependencies. There are no upward references — Core never knows about Infrastructure, Infrastructure never knows about Presentation.

flowchart TD
subgraph Presentation
WAPP[Presentation.WebAPP<br/>MVC + Razor]
WAPI[Presentation.WebAPI<br/>Internal API]
EXT[Presentation.ExternalAPI<br/>Partner API]
end
subgraph Services
WASI[Services.WebAppServicesImplementation<br/>~150 controller-services]
WASC[Services.WebAppServicesContracts<br/>Interfaces]
EMAIL[Services.EmailService]
HP[Services.HPPCBApi]
LOG[Services.LoggerService]
end
subgraph Infrastructure
PERSIST[Infrastructure.Persistance<br/>EF Core, DbContexts, repos, migrations]
end
subgraph Core
DOMAIN[Core.Domain<br/>Entities + enums]
CONTRACTS[Core.Contracts<br/>Repository + service interfaces]
SHARED[Core.Shared<br/>DTOs, constants]
end
WAPP --> WASI --> WASC --> PERSIST --> DOMAIN
WASI --> CONTRACTS
WASI --> SHARED
WASI --> EMAIL
WASI --> HP
WASI --> LOG
WAPI --> WASI
EXT --> WASI
ProjectPurposeAuth
Presentation.WebAPPMain MVC application — Razor views with Syncfusion EJ2 components. Hosts Hangfire dashboard.ASP.NET Identity cookies (+ JWT + API Key)
Presentation.WebAPIInternal admin/integration API (BOM, DPK, Imaging, OEM, Projects, Tenants, Users).API Key only
Presentation.ExternalAPIPartner-facing API for pushing inbound orders.API Key + Bearer token

Services.WebAppServicesImplementation is where the business logic lives. ~150 “controller-services” — each one is a single domain capability (e.g., DockStationControllerService, RMAControllerService, PurchaseOrderControllerService). Controllers in the Presentation layer are thin delegates that hand off to these.

Three small cross-cutting service projects sit alongside:

  • Services.EmailService — SMTP wrapper.
  • Services.HPPCBApi — typed client for HP’s Parts Content Base.
  • Services.LoggerService — Serilog integration helpers.

Infrastructure.Persistance owns EF Core. Holds three DbContexts, ~100 repositories, and all migrations.

Three projects, deliberately small:

  • Core.Domain — ~225 entity classes, enums, identity model.
  • Core.Contracts — interfaces only (IRepositoryBase<T>, IRepositoryManager, etc.).
  • Core.Shared — DTOs, constants, shared helpers.

Every entity has a repository implementing IRepositoryBase<T> (CRUD + FindByCondition). Repositories are aggregated into a RepositoryManager that:

  • Holds the RepositoryDbContext.
  • Lazily instantiates each repository on first access.
  • Exposes Save() and SaveAsync() as the unit-of-work commit.
// Inside a controller-service
var workTracking = await _repositoryManager.WorkTracking.FindByConditionAsync(...);
workTracking.StationID = newStation.StationID;
await _repositoryManager.SaveAsync();

A separate TenantRepositoryManager does the same for TenantDbContext.

🔧 Refactor finding: the RepositoryBase<T> generic is repeated across the codebase (one variant for AuditLogDbContext, another for the main context). The duplication should be collapsed once contexts are confirmed to converge. See 20-refactor-findings.md.


Mirror of RepositoryManager for services. ServiceManager exposes 100+ lazy-loaded controller-service properties:

public class ServiceManager : IServiceManager
{
public Lazy<IDockStationControllerService> DockStationControllerService { get; }
public Lazy<IRMAControllerService> RMAControllerService { get; }
// ... ~100 more
}

Controllers and other services consume one IServiceManager instead of a 100-parameter constructor.

🔧 Refactor finding: 100+ properties in one file is cognitively heavy. Domain grouping (e.g., ServiceManager.Repair, ServiceManager.Sales) would help. See 20-refactor-findings.md.


ContextDB enginePurposeTrackingMigrations table
RepositoryDbContextSQL ServerMain application data — ~120 DbSet<> properties. Inherits MultiTenantIdentityDbContext so it gets Finbuckle’s global query filter scoping rows by TenantId.Tracking__EFMigrationsHistory
TenantDbContextSQL ServerTenant registry — a single DbSet<CTLShopFloorTenantInfo>. Auto-stamps CreatedAt / LastModified (lines 27–57).Tracking__EFMigrationsHistoryTenants
FishbowlDbContextMySQL (Pomelo)Read-only mirror of Fishbowl ERP — ~60 DbSets. ChangeTracker.QueryTrackingBehavior = NoTracking globally (line 17).No-trackingn/a (no migrations)

Program.cs lines 79–100:

  1. Read Migrations:allowMigrations flag.
  2. If false: call HasPendingModelChanges() on each context; throw on mismatch. This forces explicit migration management when running against production.
  3. If true: apply pending migrations to TenantDbContext, then RepositoryDbContext.

This is the dev/prod safety mechanism described in CLAUDE.md. Same idea governs Hangfire via JOBS:allowJobsExecution.

RepositoryDbContext inherits MultiTenantIdentityDbContext<AppUser, AppRole, ...> which automatically injects WHERE TenantId = @currentTenant on every query for entities that carry TenantId. Developers don’t manually filter — Finbuckle does it.

⚠️ Assumption: no entity opts out of this filter, but IgnoreQueryFilters() overrides exist somewhere in the code (likely in cross-tenant admin operations). Worth a grep before refactor.


Finbuckle is configured in ServiceExtensions.ConfigureMultiTenant (lines 336–343):

.WithHeaderStrategy("tenant") // 1. HTTP header
.WithSessionStrategy() // 2. Session
.WithRouteStrategy("_tenant_") // 3. Route parameter
.WithEFCoreStore<TenantDbContext, CTLShopFloorTenantInfo>()

Resolution is in priority order top-to-bottom. The middleware that does the resolution is registered in Program.cs line 153.

The ExternalAPI project uses a custom ExternalApiMultiTenantStrategy (which likely reads the tenant from the partner’s API key or Bearer token claims — ⚠️ confirm by grep).


The middleware pipeline in Program.cs lines 102–172, in order:

flowchart TD
A[Request arrives] --> B[Swagger UI<br/>dev only]
B --> C[HTTP redirection]
C --> D[Static files]
D --> E[Correlation-ID middleware<br/>injects Guid into Serilog LogContext]
E --> F[404 → /Home/NotFound]
F --> G[Session]
G --> H[Finbuckle MultiTenant resolver]
H --> I[Serilog request logging<br/>LogEnricher.EnrichFromRequest]
I --> J[Authentication]
J --> K[Authorization]
K --> L[Custom ExceptionMiddleware]
L --> M[Hangfire dashboard<br/>if jobs enabled]
M --> N[MVC routing → Controller]
N --> O[ServiceManager.X.Method]
O --> P[RepositoryManager.Y.Op]
P --> Q[(SQL Server)]
P --> R[Audit.NET → MongoDB]
  • CorrelationIdMiddleware (lines 109–121) — generates a Guid per request and pushes it into LogContext so every Serilog event in the request carries the same correlation ID.
  • ExceptionMiddleware (line 145) — catches DbUpdateException, NullSyncFusionObjectException, and others; translates to a JSON error envelope.
  • SlowQueryInterceptor (line 156 of ServiceExtensions) — registered on RepositoryDbContext; logs any EF query that exceeds SlowQueryThreshold (default 5000ms).

Three concurrent schemes are registered:

SchemeIssued viaUsed byLifetime
Cookies (default)Login form on WebAPPInteractive users1 hour sliding
JWT BearerLoginControllerService via AppSettings:Token shared secretAPI consumers, mobile clients ⚠️Token-dependent
API KeyApiKeyAuthenticationHandler (custom)ExternalAPI partner calls, WebAPI internalPer-key

Identity uses a custom AppUser : IdentityUser<Guid> and AppRole : IdentityRole<Guid> (Guid PK override). Password policy is intentionally loose — no uppercase / digit / special-char requirements.

🔧 Refactor finding: loose password policy combined with secrets in appsettings.json (see §10) is the biggest security smell. The appsettings.json file is committed with live-looking connection strings, API keys (Fishbowl, Mercado Libre, SendGrid, Seq), Azure credentials, and the JWT signing secret. This must be moved to a secrets manager before any new production deploy.

A custom ResourceAccessRequirement policy (and ResourceAccessHandler):

  1. Reads the current __project__ Guid from session.
  2. Looks up the user’s roles in that project context.
  3. Builds claims dynamically from role permissions.
  4. Calls into vwRolePermissionsRepository (a DB view) to decide whether the requested action / resource is allowed.

🔧 Refactor finding: the handler reaches deeply into context.Resource → RouteEndpoint with no exception handling — fragile.


DI registration happens via extension methods over IServiceCollection, called from Program.cs. Most go through ServiceExtensions in WebAPP.

  • IRepositoryManagerRepositoryManager
  • ITenantRepositoryManagerTenantRepositoryManager
  • IServiceManagerServiceManager
  • IEmailSender
  • IPalletServiceFactory, BoxPalletControllerService
  • All Fishbowl / Mercado Libre / Reporting domain services
  • Seven IParameterDataSource implementations (resolved as IEnumerable<IParameterDataSource>)
  • ILoggerManager
  • ICacheServiceMemoryCacheService
  • IConfiguration, IHttpContextAccessor

⚠️ Assumption: the seven IParameterDataSource implementations are all actually used. Worth confirming — if any are dead, register only the live ones.


Configured in appsettings.json (lines 48–93). Three sinks active:

  • Console — local dev.
  • MSSqlServerErrorLog table, errors only.
  • Seq — central aggregation at the URL configured.

Enrichers: LogContext, MachineName, EnvironmentName, ExceptionDetails, ProcessId, ThreadId, plus custom request enrichment via LogEnricher.EnrichFromRequest. CorrelationId is added by the correlation middleware so every log line for a request shares the ID.

Minimum level: Verbose (all levels captured).

Configured in ServiceExtensions.ConfigureAuditTrail (lines 272–321). EF Core interceptor captures entity changes:

  • Database: auditLog
  • Collection: shopFloor
  • Ignored entities: AuditLog (itself), SalesImport
  • Ignored fields on WorkTracking: SystemInformationBlob, WindowsTestResultBlob (too large; stored separately)
  • User attribution: from HttpContext (line 276); defaults to "System" for background jobs

The legacy SQL AuditLog table still exists, and the PurgeAuditLogJob is disabled (commented out) because audit rows now write directly to Mongo (PurgeAuditLogJob line 47 comment). ⚠️ Possible smell: SQL AuditLog may still receive rows in some paths — confirm before assuming Mongo is the sole sink.

  • In-process: ICacheServiceMemoryCacheService.
  • Distributed session: SQL Server SessionCache table (Identity data-protection keys also stored, in Azure Blob blobstorage connection).
  • Storage: SQL Server.
  • Dashboard at /hangfire (authenticated, only when JOBS:allowJobsExecution=true).
  • ~18 recurring jobs registered via ConfigureBackgroundJobs. See 14-jobs-and-integrations.md.

FileTracked in git?Purpose
appsettings.jsonyesDefaults; contains secrets ⚠️
appsettings.Development.jsonyesDev overrides
appsettings.Staging.jsonyesStaging overrides
appsettings.Production.jsonyesProduction overrides
*.Local.jsonno (gitignored)Per-developer local overrides — connection strings, secrets
DbMigrationSettings.Local.jsonyes (suggested)Per readme.md, the one .Local.json that should be committed, defaulting allowMigrations=false for safety

Two critical config flags:

FlagDefaultEffect
Migrations:allowMigrationsfalseWhen false, throws on startup if model has pending migrations. Prevents accidentally migrating prod from a dev machine.
JOBS:allowJobsExecutionfalseWhen false, no Hangfire jobs are registered. Prevents dev machines from running production jobs against prod data.

11. Architectural smells (cross-referenced to refactor findings)

Section titled “11. Architectural smells (cross-referenced to refactor findings)”
SmellSeverityDetail
Secrets committed in appsettings.json🔴 CriticalDB passwords, API keys, JWT secret, Azure creds. Must move to KeyVault / user-secrets before next prod cycle.
Hardcoded tenant names in business logic🟠 HighTriage routing, sales-import facility mapping, HP COA job all branch on string-equal tenant name.
ServiceManager with 100+ Lazy properties🟡 MediumHard to navigate; consider domain grouping.
Duplicate RepositoryBase<T> generics🟡 MediumOne for AuditLogDbContext, one for the main context. Collapse if possible.
Loose Identity password policy🟡 MediumNo complexity requirements.
ResourceAccessHandler fragility🟡 MediumNo exception handling around context.Resource → RouteEndpoint.
Migrations flag uses string compare🟢 LowEquals("true") vs GetValue<bool>() inconsistency.
Per-context query-tracking is implicit🟢 LowFishbowlDbContext is no-tracking globally; non-obvious to a new dev.

Full running list in 20-refactor-findings.md.