DEV Community

Tristan Kilhwan Chai
Tristan Kilhwan Chai

Posted on Edited on

[Homelab AI Project] Ep.7 - Designing a Lightweight Monorepo Architecture with Node.js and Python

1. Background: "Spring Boot is Great, but My 16GB RAM is Priceless"

After wrapping up the frontend optimization with Astro and React (Episode 6), it was time to design the backend services.

When designing multi-module architectures, the default choice is often Java and Spring Boot (Gradle Multi-Module). With strong typing, mature DI containers, and structured layering, it is undeniably a solid framework.

However, my mini-PC homelab (Ryzen 5500U, 16GB RAM) runs under strict resource constraints. The core database and utility containers already consume a substantial portion of memory:

  • PostgreSQL 16 + pgvector (Capped at 4GB for caching vector indexing structures)
  • Redis 7.2 Alpine (Capped at 1GB for queuing and caching layers)
  • Prometheus + Grafana + Loki (~1GB for the APM and logging stack)
  • Gitea + Actions Runner (~200MB for CI/CD pipelines)

In this environment, booting separate Spring Boot instances for the news collector, AI processor, and mail sender would instantly swallow 300MB to 500MB of RAM per instance. Running three JVM processes would eat up 1.5GB of memory before processing a single news article, inviting OS OOM (Out Of Memory) crashes during compute-intensive vector operations.

Furthermore, implementing RAG processing requires Python library bindings (like numpy or sentence-transformers). Choosing Spring Boot meant I would eventually have to launch a separate Python runner anyway, doubling the memory footprint.

Therefore, I chose a resource-optimized stack (ADR-002):

  1. Node.js (TypeScript) Workspaces: Running high-concurrency async I/O jobs under 50MB of RAM per instance.
  2. Python RAG Core: Constructing the embedding calculations and pgvector pipelines directly in Python.
  3. Monorepo Structure: Housing both languages inside a unified repository to keep pipeline configurations clean.

2. The Solution: Node.js & Python Monorepo Layout

My multi-module layout (ADR-002) is organized as follows:

# Monorepo directory structure
AI_News/
├── packages/
│   ├── core/           # [Node.js/TS] Shared database configurations, entities, and schemas
│   ├── collector/      # [Node.js] Asynchronous RSS news collector (batch job)
│   ├── ai-engine/      # [Python] pgvector + OpenRouter RAG summarizer
│   ├── sender/         # [Node.js] Redis Queue based mass email sender
│   └── frontend/       # [Astro/React] Frontend analytics dashboard
├── infra/              # Docker Compose configurations and environment files (.env)
├── docs/               # Architecture Decision Records (ADRs) and blog drafts
├── package.json        # Root npm workspaces configuration
└── tsconfig.json       # Shared compiler options for TypeScript modules
Enter fullscreen mode Exit fullscreen mode

The core component is packages/core. Since the collector, sender, and frontend dashboard all belong to the Node.js/TypeScript stack, sharing common database entities, DTO schemas, and environment validators inside core keeps code duplication to absolute zero.


3. Implementation: Root Setup & Module Scaffolding

(1) Root Configuration (package.json)

I configured npm workspaces in the root JSON file to manage local dependency paths:

// package.json (root)
{
  "name": "@ai-news/root",
  "private": true,
  "workspaces": [
    "packages/core",
    "packages/collector",
    "packages/sender",
    "packages/frontend"
  ],
  "scripts": {
    "dev:frontend": "npm run dev -w @ai-news/frontend",
    "build:frontend": "npm run build -w @ai-news/frontend",
    "start:collector": "npm start -w @ai-news/collector",
    "start:sender": "npm start -w @ai-news/sender"
  }
}
Enter fullscreen mode Exit fullscreen mode

(2) Shared TypeScript Rules (tsconfig.json)

I established standard compiler options at the root, allowing submodules to extend the configuration:

// tsconfig.json (root)
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "esModuleInterop": true,
    "strict": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "declaration": true,
    "sourceMap": true
  }
}
Enter fullscreen mode Exit fullscreen mode

(3) The Core Package (packages/core)

Below is the scaffolding setup for the shared TypeScript package:

// packages/core/package.json
{
  "name": "@ai-news/core",
  "version": "0.1.0",
  "private": true,
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "scripts": {
    "build": "tsc"
  }
}
Enter fullscreen mode Exit fullscreen mode
// packages/core/src/types/news.ts
export interface NewsItemDTO {
  id?: string;
  title: string;
  link: string;
  source: string;
  rawContent: string;
  publishedAt: Date;
}

export interface SummaryDTO {
  newsId: string;
  summaryText: string;
  modelUsed: string;
  costIncurred: number;
  processedAt: Date;
}

#### (4) Frontend (Astro) Deployment Design: 0MB RAM Saving via Nginx Docker Multi-Stage Build
For the portfolio dashboard (to be introduced in Episode 6), running a persistent Node.js web process is unnecessary. We designed a lightweight **Astro Nginx Container** that builds static files and serves them via Nginx inside our docker-compose stack.

First, we structure `packages/frontend/Dockerfile` using multi-stage builds:
Enter fullscreen mode Exit fullscreen mode


dockerfile

Stage 1: Build static assets

FROM node:22-slim AS builder
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build

Stage 2: Serve via Nginx

FROM nginx:alpine
COPY --from=builder /usr/src/app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]


By mapping the service inside `infra/docker-compose.yml` and configuring Gitea Actions to rebuild both the collector and frontend services concurrently (`docker compose up -d --build`), we enforce a single-click deployment structure with minimal system overhead.

---

### 4. Troubleshooting & Debugging Logs

#### 🚨 Issue 1: npm Workspaces Hoisting and Missing Local Build Files
After running `npm install`, trying to boot sub-packages threw compile-time errors: `Module not found` when referencing `@ai-news/core`.

* **Cause**:
  npm workspaces hoist third-party packages to the root node directory. Because the local core package `@ai-news/core` was not built into compiled `.js` assets (`dist/`), referencing submodules crashed during runtime resolve steps.
* **Resolution**:
  I configured package pipeline triggers inside the root script runner, guaranteeing `@ai-news/core` is built prior to booting any dependent workspaces:
Enter fullscreen mode Exit fullscreen mode


json
// package.json (root)
"scripts": {
"build:core": "npm run build -w @ai-news/core",
"predev:frontend": "npm run build:core",
"prestart:collector": "npm run build:core"
}




#### 🚨 Issue 2: Docker Bridge DNS Resolving Failures (Connection Refused)
Routing API calls from the Node.js collector to the Python AI engine via `http://localhost:8000/summarize` resulted in crash errors: `ECONNREFUSED` inside container task runners.

* **Cause**:
  In containerized spaces, `localhost` points to the isolated loopback interface of that specific container. To communicate with external containers, processes must resolve network domains using Docker's internal bridge DNS resolver.
* **Resolution**:
  I targeted the Docker Compose service name (`ainews-ai-engine`) as the domain destination, mapping host targets to `http://ainews-ai-engine:8000` to establish container-to-container connections.

#### 🚨 Issue 3: Monorepo Dependency Resolution Failures during Individual Docker Builds
Building docker images with the context set directly to the subdirectory (`packages/collector`) failed with `ENOENT` errors because the build container could not resolve dependencies or reference the shared `@ai-news/core` package.
* **Cause**:
  In an npm workspaces monorepo, dependencies and shared modules are hoisted to the root directory. If the build context is restricted to the package folder, the Docker daemon cannot see files in parent or sibling directories.
* **Resolution**:
  We adjusted the Docker Compose build configuration to set the **build context to the repository root (`..`)** instead of the individual package directories. We then structured the Dockerfiles to copy the root `package.json` and `package-lock.json` first, install dependencies, and then selectively copy package sources, resolving the hoisting boundary issue.

---

### 5. Results & Metrics: Spring Boot vs. Node.js/Python

I measured the server memory footprint (RSS) against a simulated Java/Spring Boot multi-module deployment under idle conditions:

| Metric | Spring Boot (JVM, 3 instances) | Node.js (2 instances) + Python (1 instance) | Improvement |
|------|-------------------------|---------------------------------------------|------|
| **Constant RAM Footprint** | **~720 MiB** | **~124 MiB** (Node: 44MB, Python: 80MB) | **-82.7% (RAM Saved)** |
| **Cold Start Duration** | ~8.2s | **~0.9s** | **-89.0%** |
| **16GB Host RAM Saved** | ~95.5% conserved | **~99.2% conserved (600MB preserved)** | **+3.7% headroom** |

By deploying lightweight Node.js script runtimes for collectors and senders, I freed up **600MB of RAM**. This memory buffer was funneled into the pgvector similarity index caches, enabling faster semantic processing pipelines.

---

### 6. Next Up
With the Monorepo skeleton verified, Episode 8 will dive into our data harvesting layers: **Building a Node.js RSS News Collector Scheduler — Pipeline Steps and DB storage**.
Enter fullscreen mode Exit fullscreen mode

Top comments (0)