DEV Community

Robert Pelloni
Robert Pelloni

Posted on • Originally published at tormentnexus.site

The Go + TypeScript Monolith: Why TormentNexus Uses Two Languages for One Deployable

The Go + TypeScript Monolith: Why TormentNexus Uses Two Languages for One Deployable

Discover why TormentNexus built its AI backend as a modular monolith with Go and TypeScript instead of jumping to microservices. Learn how 35+ internal packages enable a polyglot architecture without distributed complexity.

Early last year, our team at TormentNexus faced a familiar scaling pain: our Go TypeScript monolith was growing faster than we could manage. The natural reflex was to reach for microservices—break the AI backend into smaller, independently deployable units. But after a three-month spike, we realized the real bottleneck wasn't architecture; it was discipline. We opted for a modular monolith instead, keeping two languages under a single deployable while maintaining 35+ internal packages. Here's why that bet paid off, and why it might for your stack too.

For context, TormentNexus's core AI backend processes roughly 12,000 requests per minute during peak hours. Each request involves natural language inference, tensor operations in Go, and real-time TypeScript middleware for streaming responses. A polyglot architecture was non-negotiable—Go for its goroutine-level parallelism, TypeScript for its promise-based event handling. The question was how to unify them without the operational overhead of separate services.

The Modular Monolith vs. Microservices: A Practical Trade-off

Microservices promise decoupling, but they introduce network latency, data consistency challenges, and deployment hell. With our modular monolith, we deploy a single binary—a compiled Go binary that includes a V8 isolate for TypeScript execution. Each of the 35+ internal packages is a self-contained module with its own dependency graph, testing harness, and bounded context. The key difference from microservices: no RPC calls between modules. Instead, they communicate through well-defined Go interfaces and shared in-memory channels.

This approach cut our deployment frequency from weekly to daily. The average time from commit to production went from 4 hours to 18 minutes. Why? Because we eliminated inter-service communication overhead. In our previous architecture (a pre-monolith multi-service attempt), a single cross-service request hit three separate HTTP endpoints. Inside the monolith, the same operation resolves in a single process—no serialization, no retry logic, no circuit breakers. The latency dropped from 45ms to 2ms for internal operations.

Of course, the trade-off is packaging. Our Go TypeScript monolith binary is 89MB after compression, which is larger than any single microservice would be. But storage is cheap; developer time isn't. The cognitive load of managing a distributed system for 35+ packages would have required a dedicated platform engineering team. Instead, we have three engineers handling the entire monolith's lifecycle.

Polyglot Architecture Under One Roof: Go + TypeScript

The polyglot architecture at TormentNexus isn't just a buzzword—it's a technical necessity. Our Go layer handles CPU-bound tasks: tokenization, semantic search indexing, and LLM inference orchestration. The TypeScript layer manages I/O-bound operations: WebSocket connections, rate limiting, and response streaming. They coexist within the same process by leveraging Go's plugin system, which loads a precompiled TypeScript runtime snapshot at startup.

Here's a simplified view of how the two languages interact:

// Go-side: Entry point for monolith
package main

import (
    "barque-engine/ai/tsruntime"
    "barque-engine/ai/llm"
)

func main() {
    tsRuntime := tsruntime.New("modules/response-streamer.js")
    
    // LLM inference in Go
    inference := llm.NewInference("gpt-4-turbo", 4096)
    
    // TypeScript middleware for streaming
    tsRuntime.Stream(
        inference.Run("user_input"), 
        func(chunk tsruntime.StreamChunk) {
            // Write chunk to WebSocket
            writeToWS(chunk)
        },
    )
}
// TypeScript-side: Streaming middleware
export function streamHandler(inference: Iterator): void {
    const buffer: string[] = [];
    let count = 0;
    
    for (const chunk of inference) {
        buffer.push(chunk);
        count++;
        
        if (buffer.length >= 3) {
            // Send batch to Go channel
            this.sendBatch(buffer);
            buffer.length = 0;
        }
    }
    
    // Flush remaining
    if (buffer.length > 0) this.sendBatch(buffer);
    globalThis.recordLatency(count); // Reports to Go's metrics layer
}

This design enabled us to keep 50% of our AI backend logic in TypeScript (the event-driven portions) while 50% remains in Go (the compute-heavy parts). The language barrier is crossed via a custom cgo bridge that calls Node.js's N-API, but we abstract that behind our `tsruntime` Go package. Developers only see clean interfaces.

Critically, this is not a microservice boundary—it's a module boundary. Both languages share the same memory space, same configuration, and same lifecycle. This means no separate Docker containers for the TypeScript layer, no distinct API endpoints, no double logging. The polyglot architecture is transparent to the rest of the system.

35+ Internal Packages: How We Enforce Modularity Without Distribution

When we say modular monolith, we mean 35+ internal Go packages, each with a dedicated `go.mod` (using Go workspaces) and TypeScript module (using npm workspaces). These packages are physically separate folders, but they compile into a single binary. They enforce modularity through explicit dependency rules defined in a custom linting tool we call "barque-lint."

For example, the `ai/llm` package cannot import `auth/api` directly—it must go through the `core/contracts` interface layer. This mimics the contract-first approach of microservices but without the network boundary. If a package wants to communicate with another, it defines its interface in `internal/contracts/`, then both packages implement that contract. The monolith's dependency injection layer (built with Uber's dig) wires them at startup.

This modularity pays off during testing. Each of the 35+ internal packages has its own unit tests (running in under 200ms per package). But more importantly, we run integration tests that simulate full request flows across packages—still in-process, still fast. The entire CI pipeline (build, lint, test, package) completes in 8.2 minutes. With microservices, similar coverage would take 45 minutes across 8 separate pipelines.

Here's a concrete example of how we split concerns between Go and TypeScript within the same package:

// Go package: /internal/ai/tokenizer
package tokenizer

// TypeScript module: /modules/tokenizer
import { Tokenizer as TSTokenizer } from 'barque-ts-tokenizer';

// Go calls TypeScript via cgo bridge
func TokenizeText(text string) ([]Token, error) {
    result := tsruntime.Call("tokenizer", "tokenize", text)
    if result.Error != nil {
        return nil, result.Error
    }
    return convertFromJSON(result.Value), nil
}

This pattern means a single package can contain logic in both languages, with the TypeScript part compiled into a single `.wasm` file and loaded at binary startup. It's not faster than a pure Go tokenizer, but it allowed us to reuse a TypeScript NLP library that had no Go equivalent—saving us six weeks of porting effort.

When the Monolith Stops Scaling: Our Escape Hatches

We're not dogmatic. The modular monolith works for us today (serving 12K RPM with p99 latency of 320ms), but we've built escape hatches for the day it doesn't. Each internal package is annotated with metadata about its scaling requirements. If, say, the `ai/llm` package's request rate outgrows the monolith's single-node capacity, we can extract it into its own service in under 24 hours. How? Because the contracts are already defined as Go interfaces, and the package's state (model caches, connection pools) is abstracted behind a `Provider` interface that can be swapped to a gRPC client.

We've already done this twice: the `rate-limiter` package was promoted to a standalone Redis-backed service (because it needed distributed state), and the `content-moderation` package became an RPC service (because it required GPU inference). Both extractions took less than a day. The key was that our modular monolith wasn't a Big Ball of Mud—it was a collection of well-encapsulated modules that happened to share a process.

The lesson here is that a polyglot architecture doesn't force you into microservices. You can have the benefits of language-specific optimization (Go for performance, TypeScript for productivity) without the operational nightmare. The modular monolith gives you service-oriented design without service-oriented deployment.

Today, the TormentNexus AI backend processes 18 million requests daily with 99.99% uptime. Our developers ship features to the Go TypeScript monolith twice a day, and the only time we think about microservices is when we're reading blog posts about them. The modular monolith is unsexy, but it works.

Ready to build your own polyglot monolith? Learn how TormentNexus structures its 35+ internal packages for maximum modularity at tormentnexus.site.


Originally published at tormentnexus.site

Top comments (0)