Skip to content

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:

OptionDescriptionConcerns
Git submodule / shared folderShared code lives in a folder; each service references it via relative pathMonorepo coupling; all services must build together; no versioning
Private NuGet packagesShared code published as versioned NuGet packages to Azure ArtifactsIndependent 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

TierPackagesRole
Tier 1 — CoreMicrotec.Domain, Microtec.Persistence, Microtec.Web.Core, Microtec.Web.HostingZero or minimal external dependencies; all other packages depend on these
Tier 2 — InfrastructureMicrotec.MongoDb, Microtec.Redis, Microtec.ServiceBus, Microtec.ReportingTechnology-specific implementations of Tier 1 interfaces
Tier 3 — IntegrationsMicrotec.Zatca, Microtec.Eta (in Eta/ folder), Microtec.ImportDomain-specific integration implementations
Tier 4 — Public APIsMicrotec.PublicApi.AppsPortal, Microtec.PublicApi.Notification, Microtec.PublicApi.HR, and othersTyped HTTP clients for inter-service communication

Why 16 Packages, Not 1?

A single Microtec.Shared mega-package was considered and rejected because:

  1. Dependency minimisation: A service that needs only IZatcaService should not pull in MassTransit, MongoDB drivers, and reporting engines. Fine-grained packages mean each service takes only what it uses.
  2. Independent versioning: Microtec.Zatca can ship a Phase 2 update without forcing all services to update simultaneously.
  3. Isolation of breaking changes: A breaking change in Microtec.MongoDb only affects services that reference it — not all 13 services.
  4. 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.Domain into Microtec.Domain.Entities and Microtec.Domain.Events would 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 pipeline

Versioning Policy

Packages use SemVer with the following rules:

Change typeVersion bumpExample
Bug fix, no API changePatch1.2.31.2.4
New public API, backward-compatibleMinor1.2.31.3.0
Breaking changeMajor1.2.32.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: Microtec

Dependency 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, implement Program.cs)
  • Bug fixes propagate: A fix in Microtec.Persistence's audit interceptor reaches all services in one pipeline run + one dotnet 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.Domain is 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.Eta package (Egyptian Tax Authority) lives in the Eta/ subfolder rather than a top-level folder because ETA integration was added post-initial design. The folder structure in MicrotecPackages/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.


  • 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)

Internal Documentation — Microtec Platform Team