Skip to main content
Version: 1.2

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.

TierLives inWrapsExample
Framework infrastructure adaptersShumoul.BackgroundJobs.Adapters packageGeneric, host-agnostic infrastructure that any Shumoul host using the same infrastructure would implement identicallyHangfireBackgroundJobAdapter, BackgroundJobTenantResolverAdapter, BackgroundJobDistributedLockAdapter, BackgroundJobLoggingAdapter, BackgroundJobNotificationBoundaryAdapter
Host job-specific adaptersThe ERP host (Shumoul.Infrastructure/Adapters/BackgroundJobs/)ERP-specific business logic — one adapter per migrated jobCampaignWorkflowExecutorJobAdapter, 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

AdapterImplementsWrapsConstructor
HangfireBackgroundJobAdapterIHangfireBackgroundJobAdapterHangfire's IBackgroundJobClient/IRecurringJobManager(IBackgroundJobClient client, IRecurringJobManager recurringJobManager)
BackgroundJobTenantResolverAdapterIBackgroundJobTenantResolverAdapterIMultiTenantContextAccessor(IMultiTenantContextAccessor contextAccessor, IOptions<BackgroundJobAdapterOptions> options)
BackgroundJobDistributedLockAdapterIBackgroundJobDistributedLockAdapterHangfire's JobStorage(JobStorage storage)
BackgroundJobLoggingAdapterIBackgroundJobLoggingAdapterILogger<T>(ILogger<BackgroundJobLoggingAdapter> logger)
BackgroundJobNotificationBoundaryAdapterIBackgroundJobNotificationAdapterNothing 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 adapterBridges toCalls
CampaignWorkflowExecutorJobAdapterINotificationWorkflowExecutorWorkerProcessPendingStepsAsync
CampaignSchedulerJobAdapterINotificationCampaignSchedulerWorkerProcessDueCampaignsAsync
CampaignCleanupJobAdapterINotificationCampaignCleanupWorkerCleanupCompletedCampaignsAsync
NotificationRetryProcessorJobAdapterINotificationRetryWorkerProcessDueRetriesAsync only
NotificationRetryExpireJobAdapterINotificationRetryWorkerExpireStaleRetriesAsync only
NotificationDeadLetterCleanupJobAdapterINotificationCleanupService (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 RunAsync should 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 RunAsync is 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 IBackgroundJobDistributedLock to 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 Adapters package from the start — extraction-after-the-fact is possible but expensive, as this framework's own Phase 0 discovery demonstrated.