Skip to content

Hexagonal Architecture — Ports & Adapters

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.

classDiagram
    direction LR

    class IBlobStorage {
        +InitiateUploadAsync()
        +CreateDownloadUrlAsync()
        +DeleteAsync()
    }

    class IBlobDescriptorReader {
        +FindAsync()
    }

    class IBlobDescriptorWriter {
        +SaveAsync()
        +UpdateAsync()
    }

    class IBlobStoreProvider {
        +SaveAsync()
        +OpenReadAsync()
        +DeleteAsync()
    }

    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 --> IBlobDescriptorReader
    DefaultBlobStorage --> IBlobDescriptorWriter
    DefaultBlobStorage --> IBlobStoreProvider
    DefaultBlobStorage --> IBlobKeyStrategy
    DefaultBlobStorage --> IBlobValidator

    IBlobDescriptorReader <|.. EfBlobDescriptorStore
    IBlobDescriptorWriter <|.. EfBlobDescriptorStore
    IBlobStoreProvider <|.. S3BlobClient
    IBlobKeyStrategy <|.. PrefixBlobKeyStrategy
    IBlobValidator <|.. MagicBytesValidator
Port (interface)FileAdapter(s)
IBlobStoragesrc/Granit.BlobStorage/IBlobStorage.csDefaultBlobStorage (orchestrator)
IBlobDescriptorReader / IBlobDescriptorWritersrc/Granit.BlobStorage/EfBlobDescriptorStore in Granit.BlobStorage.EntityFrameworkCore
IBlobStoreProvider / IPresignedUrlProvidersrc/Granit.BlobStorage/Internal/S3BlobClient in Granit.BlobStorage.S3 (implements both)
IBlobKeyStrategysrc/Granit.BlobStorage/IBlobKeyStrategy.csPrefixBlobKeyStrategy in Granit.BlobStorage.S3
IBlobValidatorsrc/Granit.BlobStorage/IBlobValidator.csMagicBytesValidator, MaxSizeValidator (built-in) + custom
ModulePortAdapters
FeaturesIFeatureStoreReader / IFeatureStoreWriterInMemoryFeatureStore, EfCoreFeatureStore
BackgroundJobsIBackgroundJobStoreReader / IBackgroundJobStoreWriterInMemoryBackgroundJobStore, EfBackgroundJobStore
WebhooksIWebhookSubscriptionReader / IWebhookSubscriptionWriterEfWebhookSubscriptionStore
SettingsISettingStoreReader / ISettingStoreWriterEfCoreSettingStore
CachingIFusionCacheFusionCache (via Granit.Caching)
EncryptionIStringEncryptionProviderAesStringEncryptionProvider
ProblemSolution
Coupling to a cloud provider (S3, Azure Blob)Ports allow swapping adapters without touching the core
Unit tests requiring a databaseInMemoryFeatureStore and InMemoryBackgroundJobStore implement Reader/Writer interfaces, replacing EF Core in tests
ISO 27001 compliance — ability to migrate from S3-compatible storage to a sovereign providerA provider package implementing IBlobStoreProvider is sufficient; the core and every consumer stay untouched
Independent NuGet packagesThe core (Granit.BlobStorage) has no dependency on EF Core or the AWS SDK

Ports are internal to the package that owns them — adapters ship as first-party provider packages, and hosts select one through its registration extension. Swapping AWS S3 for MinIO reuses the same adapter against a different endpoint (ServiceUrl + ForcePathStyle on S3BlobOptions):

builder.AddGranitBlobStorageS3();
// The rest of the application code remains unchanged
IBlobStorage blobStorage = serviceProvider.GetRequiredService<IBlobStorage>();
PresignedUploadTicket ticket = await blobStorage.InitiateUploadAsync(
"medical-documents",
new BlobUploadRequest("mri-report.pdf", "application/pdf", MaxAllowedBytes: 50_000_000),
cancellationToken);