Obsah článku / Contents
Nx Monorepo: Modulárny produkt bez bolesti hlavy

Nx Monorepo: Modulárny produkt bez bolesti hlavy

Predstavte si tento scenár: Máte produkt s 3 modulmi - Testing, Automation a Robotics. Zákazník A chce všetky tri. Zákazník B len Testing a Automation. Zákazník C len Robotics.

Tradičné riešenia:

1

Separate branches

→ Maintenance hell
2

Feature flags

→ Komplikovaný kód
3

Multiple repos

→ Duplicita kódu
4

Copy-paste

→ Nesynchronizované verzie

Moje riešenie: Nx Monorepo + Module Federation

A musím povedať: Toto je ideálne riešenie.

Môj príbeh: Qaron platform

Pracujem na Qaron.tech - modulárnej platforme s troma hlavnými produktmi:

Qaron Ecosystem:
├── Testing Module     → Test management
├── Automation Module  → Test automation pre non-tech users
└── Robotics Module    → RPA automatizácia

Business requirement: Zákazník si vyberá len moduly, ktoré potrebuje.

Technical challenge: Ako to spraviť bez maintenance hell?

Problém: Separate branches approach

Pôvodný návrh (ktorý som odmietol):

git branches:
├── main                    → All modules
├── customer-a-full         → Testing + Automation + Robotics
├── customer-b-testing-auto → Testing + Automation
├── customer-c-robotics     → Robotics only
└── customer-d-testing      → Testing only

Problémy:
❌ Bug fix = 4× commits (every branch)
❌ Feature = merge conflicts hell
❌ Deployment chaos
❌ Version management nightmare
❌ Testing each combination manually

Príklad bolesti:

bash
# Bug fix in shared auth library
git checkout main
git commit -m "Fix: Auth token refresh"

# Now merge to EVERY customer branch
git checkout customer-a-full
git merge main  # Conflict!

git checkout customer-b-testing-auto  
git merge main  # Conflict!

git checkout customer-c-robotics
git merge main  # Conflict!

# ...repeat for multiple customers
# Hours wasted on merging
# High risk of missing a branch

Riešenie: Nx Monorepo

Architektúra:

qaron-monorepo/
├── apps/
│   ├── shell/                    → Main entry point (host)
│   ├── testing-app/              → Testing module (remote)
│   ├── automation-app/           → Automation module (remote)
│   └── robotics-app/             → Robotics module (remote)
├── libs/
│   ├── shared/
│   │   ├── ui/                   → Common UI components
│   │   ├── auth/                 → Authentication logic
│   │   ├── api-client/           → gRPC/REST clients
│   │   └── utils/                → Utility functions
│   ├── testing/
│   │   ├── feature-test-cases/   → Test case management
│   │   ├── feature-execution/    → Test execution
│   │   └── data-access/          → Testing API
│   ├── automation/
│   │   ├── feature-scripts/      → Automation scripts
│   │   ├── feature-scheduler/    → Script scheduler
│   │   └── data-access/          → Automation API
│   └── robotics/
│       ├── feature-queues/       → Queue management
│       ├── feature-processes/    → Process execution
│       └── data-access/          → Robotics API
└── nx.json

Deployment configuration example:

typescript
// apps/shell/webpack.config.js
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'shell',
      remotes: {
        testing: getRemoteUrl('testing'),
        automation: getRemoteUrl('automation'),
        robotics: getRemoteUrl('robotics')
      },
      shared: {
        '@angular/core': { singleton: true, strictVersion: true },
        '@angular/common': { singleton: true, strictVersion: true },
        'rxjs': { singleton: true },
      }
    })
  ]
};

function getRemoteUrl(module: string): string {
  const customerId = process.env.CUSTOMER_ID;
  const config = getCustomerConfig(customerId);
  
  // Return URL only if customer has access to module
  return config.modules.includes(module) 
    ? `${module}@https://cdn.example.com/${module}/remoteEntry.js`
    : null;
}

// Example customer configs structure
const configs = {
  'customer-a': {
    modules: ['testing', 'automation', 'robotics']  // Full package
  },
  'customer-b': {
    modules: ['testing', 'automation']  // Partial package
  },
  'customer-c': {
    modules: ['robotics']  // Single module
  }
};

Výsledok:

bash
# One codebase
# One bug fix = one commit
git commit -m "Fix: Auth token refresh"

# Deploy once
npm run build:all

# Each customer automatically gets correct modules
# based on their config

Prečo Nx Monorepo vyhralo

1. Code sharing without pain

Pred Nx (multiple repos):

typescript
// ❌ BAD: Duplicated auth logic

// Repo 1: testing-app
export class AuthService {
  login(username, password) { /* ... */ }
  refresh() { /* ... */ }
}

// Repo 2: automation-app  
export class AuthService {
  login(username, password) { /* ... */ }
  refresh() { /* ... */ }
}

// Repo 3: robotics-app
export class AuthService {
  login(username, password) { /* ... */ }
  refresh() { /* ... */ }
}

// Bug fix = 3× commits + 3× reviews + 3× deploys

S Nx (monorepo):

typescript
// ✅ GOOD: Shared library

// libs/shared/auth/src/lib/auth.service.ts
@Injectable()
export class AuthService {
  constructor(
    private http: HttpClient,
    private tokenStorage: TokenStorageService
  ) {}
  
  login(username: string, password: string): Observable<AuthResponse> {
    return this.http.post<AuthResponse>('/api/auth/login', {
      username,
      password
    }).pipe(
      tap(response => this.tokenStorage.saveToken(response.token))
    );
  }
  
  refresh(): Observable<AuthResponse> {
    return this.http.post<AuthResponse>('/api/auth/refresh', {
      refreshToken: this.tokenStorage.getRefreshToken()
    });
  }
}

// Usage in ALL apps
import { AuthService } from '@qaron/shared/auth';

// Bug fix = 1 commit, auto-propagates to all apps

2. Dependency management

json
// ❌ BAD: Multiple repos = version hell
testing-app/package.json:    "@angular/core": "15.0.0"
automation-app/package.json: "@angular/core": "14.2.0"  ← Conflict!
robotics-app/package.json:   "@angular/core": "15.1.0"  ← Different!

// ✅ GOOD: Nx monorepo = one version
qaron-monorepo/package.json: "@angular/core": "17.0.0"

3. Atomic changes

bash
# ❌ BAD: Multiple repos
# Change API contract = 4 separate PRs

# PR 1: Update API definition (shared-types repo)
# PR 2: Update testing-app
# PR 3: Update automation-app  
# PR 4: Update robotics-app

# Must merge in correct order
# Risk: incompatible versions deployed
bash
# ✅ GOOD: Nx monorepo
# Change API contract = 1 PR

git commit -m "feat: Add pagination to test cases API"
# Changes:
#   libs/shared/api-client/
#   libs/testing/data-access/
#   apps/testing-app/
#   apps/automation-app/ (if uses test cases)

# TypeScript compiler catches ALL breaking changes
# CI tests ALL affected apps
# Deploy atomically

4. Build optimization

Nx affected commands:

bash
# Only build what changed
nx affected:build --base=main

# Example: Changed auth library
Affected apps: testing-app, automation-app, robotics-app
Not affected: shell (no changes)

# Nx builds only affected apps
# CI time: 15 min → 5 min (3× faster)

Nx cache:

bash
# Developer A builds testing-app
nx build testing-app
# Build time: 2 min

# Developer B (5 min later) builds testing-app
nx build testing-app
# Nx cache HIT!
# Build time: 2 seconds (60× faster)

5. Module Federation: Dynamic loading

Problem: Zákazník platí len za Testing module, ale dostane full bundle?

Solution: Module Federation lazy loading

typescript
// Shell app - dynamically loads only purchased modules

@Component({
  selector: 'app-root',
  template: `
    <app-header></app-header>
    <app-navigation [modules]="availableModules"></app-navigation>
    
    <router-outlet></router-outlet>
  `
})
export class AppComponent {
  availableModules = [];
  
  ngOnInit() {
    // Load only modules customer has access to
    this.loadAvailableModules();
  }
  
  private async loadAvailableModules() {
    const config = await this.configService.getCustomerConfig();
    
    if (config.modules.includes('testing')) {
      this.availableModules.push({
        name: 'Testing',
        route: 'testing',
        icon: 'test-tube'
      });
    }
    
    if (config.modules.includes('automation')) {
      this.availableModules.push({
        name: 'Automation',
        route: 'automation',
        icon: 'robot'
      });
    }
    
    if (config.modules.includes('robotics')) {
      this.availableModules.push({
        name: 'Robotics',
        route: 'robotics',
        icon: 'cog'
      });
    }
  }
}

// Routing - lazy load modules
const routes: Routes = [
  {
    path: 'testing',
    loadChildren: () => 
      import('testing/Module').then(m => m.TestingModule),
    canActivate: [ModuleAccessGuard]
  },
  {
    path: 'automation',
    loadChildren: () => 
      import('automation/Module').then(m => m.AutomationModule),
    canActivate: [ModuleAccessGuard]
  },
  {
    path: 'robotics',
    loadChildren: () =>
      import('robotics/Module').then(m => m.RoboticsModule),
    canActivate: [ModuleAccessGuard]
  }
];

// Guard - check access
@Injectable()
export class ModuleAccessGuard implements CanActivate {
  constructor(private configService: ConfigService) {}
  
  canActivate(route: ActivatedRouteSnapshot): boolean {
    const moduleName = route.url[0].path;
    const config = this.configService.getCustomerConfig();
    return config.modules.includes(moduleName);
  }
}

Výsledok (príklad):

Full package zákazník:
Bundle: All modules loaded on demand
Initial load: Shell + shared libraries

Partial package zákazník:
Bundle: Only purchased modules available
Initial load: Shell + shared libraries
Lazy load: Only testing + automation modules

Single module zákazník:
Bundle: Only one module available
Initial load: Shell + shared libraries
Lazy load: Only robotics module

Real-world príklad: Migration zo starého na nový

Scenár: Máme starú Angular aplikáciu, chceme postupne migrovať na novú verziu.

Tradičné riešenie:

bash
# Big bang migration
git checkout -b angular-migration

# Rewrite EVERYTHING
# Months of work
# High risk
# Deployment: all or nothing

Nx + Module Federation riešenie:

Fáza 1: Shell (nový) + Old app (wrapped)
qaron-monorepo/
├── apps/
│   ├── shell/              → New Angular (new)
│   └── legacy-wrapper/     → Old Angular (wrapped as remote)

Fáza 2: Migrate Testing module
qaron-monorepo/
├── apps/
│   ├── shell/              → New
│   ├── testing-app/        → New (migrated!)
│   ├── automation-legacy/  → Old
│   └── robotics-legacy/    → Old

Fáza 3: Migrate Automation module
qaron-monorepo/
├── apps/
│   ├── shell/              → New
│   ├── testing-app/        → New
│   ├── automation-app/     → New (migrated!)
│   └── robotics-legacy/    → Old

Fáza 4: Complete migration
qaron-monorepo/
├── apps/
│   ├── shell/              → New
│   ├── testing-app/        → New
│   ├── automation-app/     → New
│   └── robotics-app/       → New (complete!)

Implementation:

typescript
// Shell routing - mix old and new
const routes: Routes = [
  {
    path: 'testing',
    loadChildren: () => 
      import('testing/Module').then(m => m.TestingModule),
    // ✅ NEW: Migrated module
  },
  {
    path: 'automation',
    loadChildren: () => 
      import('automation-legacy/Module').then(m => m.LegacyModule),
    // ⚠️ OLD: Legacy wrapped module
  },
  {
    path: 'robotics',
    component: LegacyWrapperComponent,
    data: { 
      legacyUrl: 'https://old.example.com/robotics' 
    }
    // 🔴 LEGACY: iframe wrapper (temporary)
  }
];

// Legacy wrapper component
@Component({
  template: `
    <iframe 
      [src]="legacyUrl | safe" 
      style="width: 100%; height: 100vh; border: none">
    </iframe>
  `
})
export class LegacyWrapperComponent {
  @Input() legacyUrl: string;
}

Benefits:

✅ Incremental migration (module by module)
✅ Zero downtime
✅ Test each module independently
✅ Rollback easy (switch back to old module)
✅ Team can work on different modules in parallel
✅ Users don't notice the migration

Spojenie ekosystémov: Angular + React + Vue

Another use case: Automation module potrebuje React library pre script editor.

Nx makes it easy:

typescript
// Nx workspace supports multiple frameworks
qaron-monorepo/
├── apps/
│   ├── shell/              → Angular
│   ├── testing-app/        → Angular
│   ├── automation-app/     → Angular
│   └── robotics-app/       → Angular
├── libs/
│   ├── shared/
│   │   └── ui/             → Angular components
│   ├── script-editor/      → React component!
│   └── chart-lib/          → Vue component!

// Angular app can use React component via Module Federation

Implementation:

typescript
// libs/script-editor/webpack.config.js (React)
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'scriptEditor',
      filename: 'remoteEntry.js',
      exposes: {
        './Editor': './src/components/Editor.tsx'
      },
      shared: {
        react: { singleton: true },
        'react-dom': { singleton: true }
      }
    })
  ]
};

// apps/automation-app (Angular) uses React component
import { Component, ElementRef, ViewChild } from '@angular/core';
import { loadRemoteModule } from '@nx/angular/mf';

@Component({
  selector: 'app-script-editor',
  template: '<div #editorHost></div>'
})
export class ScriptEditorComponent implements OnInit {
  @ViewChild('editorHost', { static: true }) 
  editorHost: ElementRef;
  
  async ngOnInit() {
    // Load React component from remote
    const { Editor } = await loadRemoteModule('scriptEditor', './Editor');
    
    // Render React component in Angular
    const root = ReactDOM.createRoot(this.editorHost.nativeElement);
    root.render(<Editor 
      code={this.script}
      onChange={(code) => this.onScriptChange(code)}
    />);
  }
}

Výsledok:

  • Angular apps môžu používať React libraries
  • React apps môžu používať Angular components
  • Vue apps môžu používať oboje
  • Best tool for the job!

Nx commands ktoré používam denne

1. Affected builds

bash
# Build only what changed since main
nx affected:build --base=main --head=HEAD

# Real example from my workflow
git checkout -b feature/test-case-filtering
# ... make changes in libs/testing/feature-test-cases

nx affected:build --base=main
# Nx detects: testing-app affected
# Builds only: testing-app (not automation-app, robotics-app)
# Time saved: 10 min → 3 min

2. Dependency graph

bash
# Visualize project dependencies
nx graph

# Opens browser with interactive graph showing:
# - Which apps depend on which libs
# - Circular dependencies (bad!)
# - Impact analysis

Example output:

shell
├─→ @qaron/shared/ui
├─→ @qaron/shared/auth
└─→ @qaron/shared/api-client

testing-app
├─→ @qaron/shared/ui
├─→ @qaron/shared/auth
├─→ @qaron/shared/api-client
├─→ @qaron/testing/feature-test-cases
├─→ @qaron/testing/feature-execution
└─→ @qaron/testing/data-access
    └─→ @qaron/shared/api-client  (reused!)

automation-app
├─→ @qaron/shared/ui
├─→ @qaron/shared/auth
└─→ ...

3. Lint & test affected

bash
# Lint only affected projects
nx affected:lint

# Test only affected projects  
nx affected:test

# E2E test only affected apps
nx affected:e2e

4. Run many

bash
# Build all apps
nx run-many --target=build --all

# Build specific apps
nx run-many --target=build --projects=testing-app,automation-app

# Parallel execution (faster)
nx run-many --target=build --all --parallel=3

5. Workspace generators

bash
# Generate new lib
nx g @nx/angular:lib feature-notifications --directory=libs/shared

# Generate new app
nx g @nx/angular:app portal-app

# Generate component in lib
nx g @nx/angular:component button --project=shared-ui

Best practices z mojej praxe

1. Library organization

typescript
// ✅ GOOD: Granular libraries
libs/
├── shared/
│   ├── ui/              → Reusable UI components
│   ├── auth/            → Authentication logic
│   ├── api-client/      → API communication
│   └── utils/           → Utility functions
├── testing/
│   ├── feature-test-cases/     → Feature-specific
│   ├── feature-execution/      → Feature-specific
│   ├── data-access/            → Data layer
│   └── ui/                     → Testing-specific UI

// ❌ BAD: Monolithic libraries
libs/
├── shared/              → Everything in one lib (unmaintainable)
└── testing/             → All testing code in one lib

2. Access restrictions

json
// nx.json - enforce boundaries
{
  "namedInputs": {
    "default": ["{projectRoot}/**/*"]
  },
  "targetDefaults": {
    "build": {
      "dependsOn": ["^build"]
    }
  },
  "generators": {
    "@nx/angular:library": {
      "tags": ""
    }
  }
}

// libs/testing/feature-test-cases/project.json
{
  "tags": ["scope:testing", "type:feature"]
}

// Enforce: automation cannot import testing features
// .eslintrc.json
{
  "rules": {
    "@nx/enforce-module-boundaries": [
      "error",
      {
        "allow": [],
        "depConstraints": [
          {
            "sourceTag": "scope:automation",
            "onlyDependOnLibsWithTags": [
              "scope:automation",
              "scope:shared"
            ]
          }
        ]
      }
    ]
  }
}

3. Shared libraries pattern

typescript
// Pattern: barrel exports
// libs/shared/ui/src/index.ts
export * from './lib/button/button.component';
export * from './lib/input/input.component';
export * from './lib/modal/modal.component';

// Apps import from barrel
import { Button, Input, Modal } from '@qaron/shared/ui';

// NOT:
import { Button } from '@qaron/shared/ui/button';  // ❌ Deep imports

4. Module Federation configuration

typescript
// Shared dependencies configuration
shared: {
  '@angular/core': { 
    singleton: true,
    strictVersion: true,
    requiredVersion: '^17.0.0'
  },
  '@angular/common': { 
    singleton: true,
    strictVersion: true 
  },
  'rxjs': { 
    singleton: true,
    strictVersion: false  // Allow minor versions
  },
  '@qaron/shared/auth': {
    singleton: true,
    strictVersion: true,
    requiredVersion: 'auto'  // Use version from package.json
  }
}

5. CI/CD optimization

yaml
# .github/workflows/ci.yml
name: CI

on: [push, pull_request]

jobs:
  main:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
        with:
          fetch-depth: 0  # Important for Nx affected
      
      - name: Setup Node
        uses: actions/setup-node@v3
        with:
          node-version: 18
          cache: 'npm'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Nx affected
        run: |
          npx nx affected:lint --base=origin/main
          npx nx affected:test --base=origin/main --coverage
          npx nx affected:build --base=origin/main
      
      - name: Upload coverage
        uses: codecov/codecov-action@v3

Comparison: Nx Monorepo vs Alternatives

Nx vs Separate Repos

KritériumSeparate ReposNx MonorepoWinner
Code sharingDifficult (npm packages)Easy (imports)🏆 Nx
Dependency mgmtVersion conflictsSingle version🏆 Nx
Atomic changesMultiple PRsSingle PR🏆 Nx
CI/CD timeBuild all reposBuild affected only🏆 Nx
Team autonomyHighMedium🏆 Separate
Repository sizeSmallLarge🏆 Separate

Nx vs Feature Flags

KritériumFeature FlagsNx MonorepoWinner
Code complexityHigh (if/else everywhere)Low (separate modules)🏆 Nx
Bundle sizeLarge (all code)Small (lazy load)🏆 Nx
TestingComplex (all combinations)Simple (module isolation)🏆 Nx
Runtime toggleYesNo🏆 Flags
MaintenanceDifficultEasy🏆 Nx

Nx vs Lerna

KritériumLernaNxWinner
Build speedSlowFast (cache + affected)🏆 Nx
Dependency graphNoYes🏆 Nx
Affected detectionNoYes🏆 Nx
GeneratorsNoYes🏆 Nx
Module FederationNo supportBuilt-in🏆 Nx

Kedy NEPOUŽIŤ Nx Monorepo

Buďme realistickí. Nx nie je silver bullet.

1. Malé projekty

Projekt: Simple landing page
Team: 1-2 developers
Complexity: Low

Nx overhead: Not worth it
Better: Simple Create React App

2. Rôzne release cadences

Projekt: API (monthly releases) + Frontend (daily releases)
Problem: Monorepo wants atomic releases

Better: Separate repos with clear contracts

3. Different teams with no code sharing

Team A: E-commerce platform (React)
Team B: Admin dashboard (Angular)
Team C: Mobile app (React Native)

No shared code, no shared dependencies
Better: Separate repos

4. Very strict access control

Team A: Cannot see Team B's code (legal/compliance)

Problem: Monorepo = shared access
Better: Separate repos with strict permissions

Záver: Nx pre modulárne produkty

Na základe skúseností s Nx na Qaron platforme môžem povedať:

Výhody ktoré nás presvedčili:

  • 🚀 Modulárny produkt bez branch hell
  • 💪 Code sharing cez shared libraries
  • 🔄 Atomic changes cez celý system
  • 📦 Dynamic loading len kúpených modulov
  • 🎯 Build optimization (affected + cache)
  • 🌐 Multi-framework support (Angular + React)

Kedy ho používame:

  • Modulárny produkt (zákazník platí za moduly)
  • Potreba zdieľať kód medzi aplikáciami
  • Multiple apps v jednom domaine
  • Incremental migration (starý → nový)

Kedy ho NEPOUŽÍVAME:

  • Malé projekty (1-2 apps)
  • Žiadne code sharing
  • Different release cadences
  • Strict access control requirements

Best pattern pre nás:

Nx Monorepo
  + Module Federation (dynamic loading)
  + Shared libraries (DRY)
  + Customer configs (modularity)
  = Modulárny produkt without pain

Otázka nie je "Mám použiť monorepo?"

Otázka je "Ako dlho ešte chcem mergovať separate branches?"


Článok napísal Solution Architect, ktorý implementoval Nx monorepo architektúru pre modulárny enterprise produkt s viacerými aplikáciami a zdieľanými knižnicami.

Stack: Nx, Angular, Module Federation, TypeScript, Spring Boot backend, gRPC communication.

Check out Qaron.tech - modulárna platforma postavená na moderných princípoch.

Začnite s Nx dnes. Váš future self vám poďakuje.

Všetky články