Introduction
Large enterprise monorepos quickly suffer from slow CI/CD pipelines. A full build of every Angular app, library and .NET service on every commit wastes CPU, increases feedback time, and costs money. An Intelligent Build System detects which modules are impacted by a change and builds only those — plus their dependents — while ensuring correctness.
This article explains how to design, implement and operate an intelligent build system for hybrid stacks: Angular (frontend) and .NET (backend). You will get:
High-level architecture and workflows (block diagrams).
File-to-module dependency analysis techniques.
Practical detection scripts and CI pipeline examples.
Strategies for correctness (tests, contracts, change validation).
Caching, distributed execution, and observability.
Operational guidance and common pitfalls.
The goal: faster developer feedback, lower CI cost, and reliable builds for production deployment.
Problem Statement
In a monorepo with many frontend libs, multiple Angular apps, and several .NET microservices:
Small UI change can re-trigger tens of builds.
Backend interface change requires rebuilding many services.
CI queues balloon and developer feedback becomes slow.
We want a system that:
Determines the minimal set of modules needing rebuild for a given commit (impacted set).
Builds and tests only that set (and safe dependents).
Preserves correctness (no missing rebuilds).
Integrates with existing CI/CD and developer workflows.
High-Level Architecture
┌─────────────┐ ┌──────────────┐ ┌───────────────┐
│ Developer │ ---> │ CI Trigger │ ---> │ Impact Analyzer│
└─────────────┘ └──────┬───────┘ └──────┬────────┘
│ │
▼ │
┌───────────┐ │
│ Git Diff │<-------------┘
└────┬──────┘
│
▼
┌───────────────────────┐
│ Module Graph Service │
│ (Angular TS + .NET) │
└─────────┬─────────────┘
│
▼
┌──────────────────────────┐
│ Build Orchestrator (CI) │
│ (local / remote cache / │
│ distributed executors) │
└─────────┬────────────────┘
│
▼
┌─────────────────┐
│ Artifact Store │
└─────────────────┘
Key Concepts
Module: logical unit — Angular app/lib, .NET project, shared library.
Dependency Graph: directed graph where an edge A→B means A depends on B (if B changes, A may need rebuild).
Impacted Set: the minimal set of modules that must be rebuilt because they were changed or they transitively depend on changed modules.
Safe Dependents: set of modules that may be rebuilt to be conservative (e.g., if interfaces changed).
Change Contract: explicit markers that indicate whether a change is backwards-compatible or breaking (e.g., API version bump, public API changes).
Build Cache: store of previous build outputs keyed by input hash to skip rebuilds when possible.
How Impact Detection Works
1. File Diff → Affected Files
Get changed files for the commit/PR:
# changed files vs main
git fetch origin main
git diff --name-only origin/main...HEAD
2. File → Module Mapping
Map each changed file to a module. Techniques:
Path-based mapping: repo layout uses folders per project: libs/ui/button/** → libs/ui/button module.
tsconfig / project.json: read angular.json or tsconfig.json projects section to know Angular projects and their root.
.csproj mapping: each .NET project lives in a folder; map changed .cs/.csproj file to that project.
Custom manifest: maintain module-manifest.json mapping globs → module name.
Example module-manifest.json:
{
"modules": [
{ "name": "ui-button", "globs": ["libs/ui/button/**"] },
{ "name": "orders-api", "globs": ["src/Services/Orders/**"] }
]
}
Apply with minimatch or gitignore-style matching.
3. Module Graph Construction
Two build-time graphs required:
dotnet msbuild -nologo -t:GenerateRestoreGraphFile -p:RestoreGraphOutputPath=graph.dg
# or use dotnet list <proj> reference -- include-transitive
dotnet list src/Services/Orders/Orders.csproj reference
Store combined graph in a canonical format (JSON adjacency list). This graph is cached and updated when projects change.
4. Transitive Closure → Impacted Modules
For each module representing a changed file, compute all modules that depend on it (reverse graph traversal). That union is the impacted set.
If module A depends on B and B changed → A is impacted.
Optionally include only direct dependents or include transitive layers; generally transitive closure is safe.
Dealing With APIs And Contracts
Not all changes require rebuilding dependents:
Approaches
Conservative (safe): always rebuild dependents. Simple but might rebuild too much.
Contract Aware (preferred)
Use public API extractor (TypeScript api-extractor for libs; .NET PublicApiCompat or ApiPort) to compute API surface hash.
If API hash unchanged, skip dependents. If changed, include dependents.
For .NET, use dotnet format? Better: use PublicApiAnalyzers or extract public API via reflection/unit tests and compare snapshots.
Example flow:
On every merge to main, extract API snapshot per project and store in artifacts.
On PR, run API extractor for changed module; if snapshot differs → mark breaking change.
This enables precise decisions: implementation-only change → no dependent rebuild.
Angular Specifics
Angular CLI projects are declared in angular.json. Use it to map projects to folders.
Use ng build --project <name> and ng test --project <name> to build/test single project.
For TypeScript libs, tsc --build with project references provides incremental builds.
Nx (highly recommended)
nx affected:apps --base=origin/main gives affected apps.
nx affected --target=build will run build only for affected projects and their dependents.
Nx includes computation of dependency graph (works great for mixed TS monorepos).
If not using Nx, implement script:
changed=$(git diff --name-only origin/main...HEAD)
modules=$(node scripts/map-files-to-modules.js $changed)
impacted=$(node scripts/compute-impacted.js $modules graph.json)
for m in $impacted; do ng build --project $m; done
.NET Specifics
Use SDK-style projects with <ProjectReference> elements for intra-repo references; use dotnet list package to find dependencies.
dotnet build is incremental; MSBuild will skip projects where input hash unchanged — but only when run for the entire solution. We need to tell CI which projects to build.
Techniques
Use dotnet build <path-to-csproj> for each impacted project and its dependents.
Generate project dependency graph with MSBuild or use dotnet list <proj> reference.
Use msbuild /t:Restore;Build /p:BuildProjectReferences=false and then build only required projects — this avoids building the whole solution.
For interface-aware detection
Use a tool to compute public API (e.g., ApiCompat, PublicApiGenerator), snapshot it, and compare diffs.
CI Pipeline Examples
GitHub Actions (simplified)
name: CI
on: [push, pull_request]
jobs:
analyze:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node
uses: actions/setup-node@v3
- name: Setup .NET
uses: actions/setup-dotnet@v3
with: dotnet-version: '8.0.x'
- name: Detect changed files
id: diff
run: |
git fetch origin main
git diff --name-only origin/main...HEAD > changed.txt
echo "::set-output name=files::$(cat changed.txt | jq -R -s -c 'split("\n")')"
- name: Map to modules + compute impacted
id: impacted
run: |
node scripts/compute-impacted.js changed.txt graph.json > impacted.json
echo "::set-output name=modules::$(cat impacted.json)"
build:
needs: analyze
runs-on: ubuntu-latest
strategy:
matrix:
module: ${{ fromJson(needs.analyze.outputs.modules) }}
steps:
- uses: actions/checkout@v3
- name: Build module
run: |
if [[ "${{ matrix.module.type }}" == "angular" ]]; then
ng build --project ${{ matrix.module.name }}
else
dotnet build ${{ matrix.module.path }} -c Release
fi
This pattern parallelizes per module; combine with caching for speed.
Azure DevOps / GitLab
Same idea: run analyzer job and then a dynamic matrix job that builds impacted modules.
Build Cache And Distributed Execution
Building only impacted modules still benefits greatly from build cache & remote execution.
Options
Remote Cache: Nx Cloud, Gradle Build Cache, Bazel remote cache. For .NET, use caching via nuget and artifact caching; OSS projects exist for sourcelink-style caching.
Distributed Build Execution: Use Azure Pipelines distributed agents or GitHub Actions with concurrency. For larger orgs, consider Bazel or Buildbarn for hermetic distributed builds.
Artifact Store: push module artifacts (npm tarballs, NuGet packages, docker images) to a cache to short-circuit downstream jobs.
Cache key design: include commit hashes of module sources + relevant dependency API hashes + environment variables.
Tests: Unit, Integration, E2E
Correctness requires tests. Build system should:
Run unit tests for changed modules.
Run integration tests for dependents if API changed.
Run critical E2E tests selectively (smoke tests) or route full E2E to nightly pipelines.
Example rule
Use test selection:
Observability And Metrics
Track:
Average CI duration per PR (before & after).
Number of modules built per PR.
Cache hit rate.
False negatives (missed impacted modules that caused runtime failures).
API snapshot differences and frequency.
Store metrics in Prometheus/Grafana or cloud CI analytics. Alert on false negatives and increased E2E failures correlated with skipped builds.
Security And Supply Chain Considerations
Ensure analyzer scripts run in CI with minimal privileges and with controlled toolchain versions.
Verify generated artifacts (npm/nuget) are signed or have checksum verification.
Prevent arbitrary code execution: analyzer should only run allowed scripts; validate module-manifest mappings.
For build caches and remote executors, secure the artifact store and use signed tokens.
Operational Playbook
Start conservative: begin by rebuilding changed modules + direct dependents for a period (e.g., 2 weeks) while logging potential misses.
Instrument and move to API-aware mode (public API snapshots) when confident.
Maintain a “safety” full-run nightly to catch anything missed.
Keep manual override: allow PR to trigger full build if investigator suspects risk.
Keep main protected: require successful CI (full or partial as policy dictates) before merge.
Common Pitfalls And How To Avoid Them
Incorrect file→module mapping: maintain manifest and validate with tests.
Missed transitive dependencies: always compute transitive closure in graph.
API changes not detected: use API extractor and snapshot comparisons.
Build cache inconsistency: include all inputs (env vars, toolchain versions) in cache key.
Too-conservative rules (no gain): iterate to reduce rebuild footprint gradually.
Sample Scripts and Tools
scripts/compute-impacted.js — takes changed files and graph.json, outputs impacted modules.
scripts/extract-angular-graph.ts — parse angular.json and tsconfig references.
scripts/extract-dotnet-graph.ps1 — use dotnet list for project refs and produce JSON.
Small Bash example to list changed Angular projects (without Nx):
changed_files=$(git diff --name-only origin/main...HEAD)
projects=()
for projDir in $(jq -r '.projects | keys[]' angular.json); do
root=$(jq -r ".projects[\"$projDir\"].root" angular.json)
for f in $changed_files; do
if [[ $f == $root* ]]; then
projects+=($projDir)
break
fi
done
done
echo "${projects[@]}"
Roadmap For Maturity
Start: path-based mapping + transitive closure + conservative rebuild.
Add: public API snapshot detection for safer skipping.
Add: build cache + artifact store, parallelized matrix builds.
Add: distributed remote execution (Bazel, BuildGrid) for large orgs.
Add: automatic rollback policies and full-run verification triggers if anomalies detected.
Conclusion
An Intelligent Build System saves time and money while improving developer productivity. The core ingredients are:
Accurate file→module mapping.
Reliable dependency graph for Angular and .NET projects.
API-aware decisions to skip unnecessary dependent builds.
Robust caching, parallel execution and artifact management.
Sound testing and observability to guarantee safety.