7. Adapters
7.1 Infrastructure adapters vs. host adapters — the two tiers
This framework uses two distinct tiers of adapter, and knowing which tier a given adapter belongs to is the single most important design decision to understand before extending it.
| Tier | Lives in | Wraps | Example |
|---|---|---|---|
| Framework infrastructure adapters | Shumoul.BackgroundJobs.Adapters package | Generic, host-agnostic infrastructure that any Shumoul host using the same infrastructure would implement identically | HangfireBackgroundJobAdapter, BackgroundJobTenantResolverAdapter, BackgroundJobDistributedLockAdapter, BackgroundJobLoggingAdapter, BackgroundJobNotificationBoundaryAdapter |
| Host job-specific adapters | The ERP host (Shumoul.Infrastructure/Adapters/BackgroundJobs/) | ERP-specific business logic — one adapter per migrated job | CampaignWorkflowExecutorJobAdapter, CampaignSchedulerJobAdapter, CampaignCleanupJobAdapter, NotificationRetryProcessorJobAdapter, NotificationRetryExpireJobAdapter, NotificationDeadLetterCleanupJobAdapter |
7.2 Two-Tier Adapter Model — why this framework diverges from the Notification Framework
The Notification Framework's own rule puts all of its adapters in the ERP host, because its adapters
wrap ERP-specific data access with no host-agnostic version possible (e.g. bridging IRepositoryAsync to a
framework repository port only makes sense in terms of the ERP's own persistence layer).
The Background Jobs Framework's infrastructure adapters are different in kind: wrapping Hangfire and the
Shumoul MultiTenancy package is not ERP-specific — "any Shumoul host using Hangfire and the Shumoul
MultiTenancy package would implement HangfireBackgroundJobAdapter identically." Because these adapters
carry no ERP-specific logic at all, they ship inside the framework's own Adapters package rather than
being re-implemented by every consuming host.
Job-specific adapters remain host-owned, exactly matching the Notification Framework's convention, because they do carry ERP-specific logic (which worker interface to call, in what order).
Platform rule this produced, applying to every future module: "A future module must decide per-adapter, not assume one tier fits every adapter it has." Don't copy either framework's placement rule verbatim — validate each adapter's placement against whether it's genuinely host-agnostic or genuinely business-specific.
7.3 The 5 framework infrastructure adapters, in detail
| Adapter | Implements | Wraps | Constructor |
|---|---|---|---|
HangfireBackgroundJobAdapter | IHangfireBackgroundJobAdapter | Hangfire's IBackgroundJobClient/IRecurringJobManager | (IBackgroundJobClient client, IRecurringJobManager recurringJobManager) |
BackgroundJobTenantResolverAdapter | IBackgroundJobTenantResolverAdapter | IMultiTenantContextAccessor | (IMultiTenantContextAccessor contextAccessor, IOptions<BackgroundJobAdapterOptions> options) |
BackgroundJobDistributedLockAdapter | IBackgroundJobDistributedLockAdapter | Hangfire's JobStorage | (JobStorage storage) |
BackgroundJobLoggingAdapter | IBackgroundJobLoggingAdapter | ILogger<T> | (ILogger<BackgroundJobLoggingAdapter> logger) |
BackgroundJobNotificationBoundaryAdapter | IBackgroundJobNotificationAdapter | Nothing yet — see §7.4 | (no dependencies — no-op body) |
HangfireBackgroundJobAdapter is the only class anywhere in the framework's 5 packages that imports
Hangfire.* types — this confinement is itself a tested architectural invariant.
7.4 A known gap — the notification adapter
BackgroundJobNotificationBoundaryAdapter.NotifyAsync is, in current shipped code, a literal Task.CompletedTask
no-op. ADR-006 intended a real adapter routing job-completion notifications through the Notification
Framework; the real implementation was never built. This is tracked as AP-03 in the framework's own
anti-pattern audit — a permanent no-op stub, not a transitional placeholder actively being finished. In
practice, none of the six migrated jobs relies on this adapter for notification delivery — each dispatches
notifications directly from inside its own worker/business service instead. See
§15.4 — ADR-006.
7.5 The 6 host job-specific adapters (already in production)
| Job adapter | Bridges to | Calls |
|---|---|---|
CampaignWorkflowExecutorJobAdapter | INotificationWorkflowExecutorWorker | ProcessPendingStepsAsync |
CampaignSchedulerJobAdapter | INotificationCampaignSchedulerWorker | ProcessDueCampaignsAsync |
CampaignCleanupJobAdapter | INotificationCampaignCleanupWorker | CleanupCompletedCampaignsAsync |
NotificationRetryProcessorJobAdapter | INotificationRetryWorker | ProcessDueRetriesAsync only |
NotificationRetryExpireJobAdapter | INotificationRetryWorker | ExpireStaleRetriesAsync only |
NotificationDeadLetterCleanupJobAdapter | INotificationCleanupService (via NotificationDeadLetterCleanupWorker) | CleanupDeadLettersAsync then CleanupStaleRetriesAsync |
Each implements IBackgroundJob with a RunAsync body that is a null-check plus exactly one (or, for the
dead-letter cleanup adapter, exactly two sequential) call to the pre-existing business worker — no repository
access, no transaction management, no business logic of its own. See
§14 — Job Migration Guide for how each was introduced.
7.6 Best practices
- Decide adapter tier per adapter, never by analogy to a sibling framework's blanket rule.
- A framework infrastructure adapter must genuinely be host-agnostic — if it needs to know anything ERP-specific to function, it belongs in the host, not the framework.
- A job-specific adapter's
RunAsyncshould be as close to a single line as possible: a null-check and one call to the pre-existing worker method. If it's doing more than that, business logic is leaking into the adapter layer. - Never let a job-specific adapter open its own database transaction — transactions belong entirely to the business worker/service being called.
- When two jobs share a worker interface with more than one method (e.g.
INotificationRetryWorker), write an explicit isolation test proving each adapter calls only its own assigned method.
7.7 Common mistakes
- Assuming the pipeline gives you tenant scoping, a distributed lock, and notification delivery "for
free." It does not — the pipeline's actual
RunAsyncis only a cancellation check, invoking the next delegate, and classify-and-rethrow on exception. Tenant context, locking, and notification are separate, opt-in services a job's own adapter must wire up explicitly if it needs them. This exact misconception is the framework's own documented AP-01 finding — a stale XML doc comment on the pipeline describes an aspirational, richer stage sequence the shipped code does not implement. - Reaching for
IBackgroundJobDistributedLockto fix a duplicate-row-processing bug. This is the wrong layer — see §9.4. - Implementing more than one adapter interface in a single class. Every adapter in this framework implements exactly one interface — this is a tested invariant, not a style preference.
- Putting a new framework-level infrastructure adapter in the ERP host "for now." If it's genuinely
host-agnostic, it belongs in the framework's
Adapterspackage from the start — extraction-after-the-fact is possible but expensive, as this framework's own Phase 0 discovery demonstrated.