Appearance
ADR-009: NuGet Package Decomposition (16 Packages, 4-Tier Hierarchy)
Status
Accepted
Date
2023-Q3
Context
With 14+ microservices each implementing Clean Architecture, a large amount of code was being duplicated across services:
- Every service re-implemented
BaseEntity,IUnitOfWork, JWT middleware, EF Core interceptors, Serilog configuration, OpenTelemetry setup, and MediatR pipeline behaviours - Fixing a bug in the audit interceptor required updating 14 separate repositories
- New services took days to bootstrap from scratch — developers copy-pasted from the Accounting service
- Cross-service consistency degraded over time as each copy diverged
The team evaluated two approaches:
| Option | Description | Concerns |
|---|---|---|
| Git submodule / shared folder | Shared code lives in a folder; each service references it via relative path | Monorepo coupling; all services must build together; no versioning |
| Private NuGet packages | Shared code published as versioned NuGet packages to Azure Artifacts | Independent versioning; each service upgrades at its own pace; clear API surface |
Decision
Decompose shared infrastructure code into 16 NuGet packages organised in a 4-tier dependency hierarchy, published to a private Azure Artifacts feed.
The 4-Tier Hierarchy
| Tier | Packages | Role |
|---|---|---|
| Tier 1 — Core | Microtec.Domain, Microtec.Persistence, Microtec.Web.Core, Microtec.Web.Hosting | Zero or minimal external dependencies; all other packages depend on these |
| Tier 2 — Infrastructure | Microtec.MongoDb, Microtec.Redis, Microtec.ServiceBus, Microtec.Reporting | Technology-specific implementations of Tier 1 interfaces |
| Tier 3 — Integrations | Microtec.Zatca, Microtec.Eta (in Eta/ folder), Microtec.Import | Domain-specific integration implementations |
| Tier 4 — Public APIs | Microtec.PublicApi.AppsPortal, Microtec.PublicApi.Notification, Microtec.PublicApi.HR, and others | Typed HTTP clients for inter-service communication |
Why 16 Packages, Not 1?
A single Microtec.Shared mega-package was considered and rejected because:
- Dependency minimisation: A service that needs only
IZatcaServiceshould not pull in MassTransit, MongoDB drivers, and reporting engines. Fine-grained packages mean each service takes only what it uses. - Independent versioning:
Microtec.Zatcacan ship a Phase 2 update without forcing all services to update simultaneously. - Isolation of breaking changes: A breaking change in
Microtec.MongoDbonly affects services that reference it — not all 13 services. - Clear responsibility: Each package has a single owner and a defined API surface.
Why Not More?
Splitting further (e.g., one package per entity or per feature) was also considered and rejected:
- The total number of cross-service dependencies would make version management unmanageable
- Tier 1 packages are cohesive units; splitting
Microtec.DomainintoMicrotec.Domain.EntitiesandMicrotec.Domain.Eventswould add overhead with no practical benefit
Publishing Strategy
Source Repository
All packages live in the MicrotecPackages/ repository (separate from Microtec.Platforms):
MicrotecPackages/
├── src/
│ ├── Microtec.Domain/
│ ├── Microtec.Persistence/
│ ├── Microtec.Web.Core/
│ ├── Microtec.Web.Hosting/
│ ├── Microtec.Redis/
│ ├── Microtec.ServiceBus/
│ ├── Microtec.MongoDb/
│ ├── Microtec.Reporting/
│ ├── Zatca/
│ │ ├── Microtec.Zatca/
│ ├── Eta/
│ │ ├── Microtec.Eta/
│ ├── Import/
│ │ ├── Microtec.Import/
│ └── PublicApis/
│ ├── Microtec.PublicApi.AppsPortal/
│ ├── Microtec.PublicApi.Notification/
│ └── Microtec.PublicApi.HR/
├── tests/
└── build/
└── publish-packages.yml ← ADO pipelineVersioning Policy
Packages use SemVer with the following rules:
| Change type | Version bump | Example |
|---|---|---|
| Bug fix, no API change | Patch | 1.2.3 → 1.2.4 |
| New public API, backward-compatible | Minor | 1.2.3 → 1.3.0 |
| Breaking change | Major | 1.2.3 → 2.0.0 |
Major version bumps require a deprecation period: the old version remains published for 3 months, with an XML deprecation notice in the package metadata.
Publishing Pipeline
yaml
# build/publish-packages.yml
trigger:
branches: { include: [main] }
paths: { include: [src/**] }
stages:
- stage: Build
jobs:
- job: BuildAndPack
steps:
- script: dotnet build MicrotecPackages.sln -c Release
- script: dotnet pack MicrotecPackages.sln -c Release --no-build
displayName: Pack NuGet packages
- stage: Publish
dependsOn: Build
jobs:
- job: PublishToFeed
steps:
- task: NuGetAuthenticate@1
- task: NuGetCommand@2
inputs:
command: push
packagesToPush: '**/*.nupkg'
nuGetFeedType: internal
publishVstsFeed: MicrotecDependency Lock in Consumer Services
Consumer services reference packages with a minimum version floor (not a fixed version) to allow patch updates automatically:
xml
<!-- MyService.Apis/MyService.Apis.csproj -->
<PackageReference Include="Microtec.Web.Hosting" Version="2.*" />
<PackageReference Include="Microtec.Zatca" Version="1.*" />The * wildcard resolves to the latest patch within the major version, providing automatic security patches without breaking changes.
Consequences
Positive
- Bootstrap time reduced: New service setup goes from 2 days (copy-paste + debug) to 2 hours (add
Microtec.Web.Hosting, implementProgram.cs) - Bug fixes propagate: A fix in
Microtec.Persistence's audit interceptor reaches all services in one pipeline run + onedotnet restore - Consistent cross-cutting behaviour: All services log in the same structured format, use the same OpenTelemetry exporter, and apply the same validation pipeline — enforced by package code, not convention
- Security updates centralised: A CVE in a transitive dependency is patched in one package; all consumers pick it up on next restore
- Clear API contracts: The
Microtec.PublicApi.*packages define the typed client contracts between services — changes to the API surface are explicit and versioned
Negative
- Version upgrade coordination: When a major version is released, all consuming services must be updated. With 14+ services, this is a coordinated effort spanning 1–2 sprints.
- Delayed feedback on breaking changes: A breaking change in
Microtec.Domainis not discovered until consuming services update and build. Mitigated by running consumer builds in CI as a post-publish validation step. - Azure Artifacts feed as a dependency: All developers and all pipelines must authenticate to the feed. See NuGet Setup.
- Publishing overhead: A change to a base package requires: PR → review → merge → pipeline publish → consumer services update. Faster iteration happens by referencing the package source directly during development.
Neutral
- The
Microtec.Etapackage (Egyptian Tax Authority) lives in theEta/subfolder rather than a top-level folder because ETA integration was added post-initial design. The folder structure inMicrotecPackages/src/is otherwise feature-grouped, not tier-grouped — the tier hierarchy is a logical dependency concept, not a folder structure.
Known Intentional Quirk
MonogoDb typo is intentional
The configuration key read by Microtec.MongoDb is MonogoDb__ConnectionString — note the transposed letters in "Monogo". This is a typo that was baked into the published package before discovery. Fixing it would be a breaking change requiring all consumers to rename their appsettings key. The typo has been documented and the package published with this key name. Do not "fix" it in appsettings — it will break MongoDB connectivity.
Related ADRs
- ADR-001: Microservices Architecture (each service consumes these packages)
- ADR-006: Clean Architecture (packages map to architecture layers)
- ADR-007: DevSecOps Pipeline (package publish pipeline is a subset of the 16-stage template)