What’s a Monorepo?#

A monorepo is a single git repository that holds multiple related projects. Instead of having separate repos for your backend, frontend, and shared libraries, everything lives together. You can see all the code, make cross-cutting changes in one commit, and ensure that related projects stay compatible.

The challenge: different parts of the codebase use different languages and build tools. Go uses go build. C++ uses cmake or make. TypeScript uses npm and vite. How do you tie these together into one coherent build?

The naive approach — a shell script that runs each tool in sequence — breaks down quickly. Steps run in the wrong order. Incremental builds don’t work (everything rebuilds from scratch). It’s hard to know which file changes should trigger which rebuilds.

Bazel solves this by modeling the entire build as a single dependency graph, regardless of language.

The Core Idea: Every Build Action Is a Node#

In Bazel, you describe what to build (targets) and what they depend on (dependencies). Bazel figures out the correct order automatically and only rebuilds what changed.

A simple example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# backend/BUILD.bazel
go_binary(
    name = "theron",
    srcs = glob(["*.go"]),
    deps = [
        "//backend/collector",
        "//backend/server",
        "@com_github_spf13_cobra//:cobra",
    ],
)

This says: to build the theron binary, compile all .go files in this directory, and link them against collector, server, and the cobra library. Bazel knows that if collector needs to be rebuilt first, it builds that first. If nothing changed, it uses the cached result.

The magic: this works identically for Go, C++, and TypeScript — the same dependency tracking, the same incremental builds, the same cache.

MODULE.bazel: Declaring Your Dependency Universe#

Bazel 6+ uses a system called Bzlmod. Your MODULE.bazel file is the manifest for every external dependency across all languages:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
module(
    name = "theron_qt",
    version = "0.1.0",
)

# C++ dependencies
bazel_dep(name = "rules_cc",    version = "0.2.17")
bazel_dep(name = "emsdk",       version = "5.0.5")    # Emscripten (C++ → WASM)
bazel_dep(name = "imgui",       version = "1.92.6-docking.bcr.1")
bazel_dep(name = "flatbuffers", version = "25.1.24")

# Go dependencies
bazel_dep(name = "rules_go",    version = "0.60.0")
bazel_dep(name = "gazelle",     version = "0.43.0")   # auto-generates BUILD files

# TypeScript/JavaScript
bazel_dep(name = "aspect_rules_js", version = "3.0.3")
bazel_dep(name = "aspect_rules_ts", version = "3.8.8")

# Shared (used by Go and TS)
bazel_dep(name = "rules_proto", version = "7.1.0")

One file declares the Go version, the npm package set, the C++ toolchains, and the Protobuf generators. No more hunting through three separate package.json, go.mod, and CMakeLists.txt files to understand what version of a shared dependency each part of the project uses.

Platform Transitions: Same Source, Two Targets#

This is the most powerful Bazel feature in this stack. A platform transition lets you compile the same C++ source code for different targets without maintaining two separate source trees.

The use case: the trading chart is a C++ application. On desktop, it uses Vulkan for rendering. In the browser, it uses WebGPU (via Emscripten, which compiles C++ to WebAssembly). The application logic is identical — only the rendering backend differs.

1
2
3
4
5
6
7
8
# platforms/BUILD.bazel
platform(
    name = "wasm",
    constraint_values = [
        "@platforms//os:wasi",
        "@platforms//cpu:wasm32",
    ],
)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# chart/BUILD.bazel
cc_binary(
    name = "krag_chart_native",
    srcs = glob(["src/**/*.cpp"]),
    deps = [":krag_chart_lib", ":vulkan_backend"],
    # default: compiles with host GCC → native Vulkan binary
)

cc_binary(
    name = "krag_chart_wasm",
    srcs = glob(["src/**/*.cpp"]),
    deps = [":krag_chart_lib", ":webgpu_backend"],
    target_compatible_with = ["@platforms//os:wasi"],
    # Bazel sees the wasm platform → switches to emcc compiler for entire dep tree
)

When you run bazel build //chart:krag_chart_wasm, Bazel transitions every cc_library in the transitive dependency chain to use Emscripten’s emcc compiler instead of the host GCC. The same krag_chart_lib gets compiled twice — once for Vulkan, once for WASM — with completely different compiler flags.

Debugging tip: if you see gcc: unrecognized option -msimd128 (a WASM-only SIMD flag), the platform transition isn’t firing. That flag is Emscripten-only — if GCC is seeing it, something in your platform or toolchain configuration is wrong.

The archive_override Workaround#

Real monorepos hit dependency version conflicts. Here’s a real example from the FlatBuffers dependency:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# This is in MODULE.bazel — read the comment, it explains a real problem

# DO NOT replace with a plain bazel_dep on flatbuffers from BCR.
# The BCR entry pulls rules_foreign_cc with incompatible_use_toolchain_transition,
# which our Bazel version rejects.
archive_override(
    module_name = "flatbuffers",
    patch_cmds = [
        "printf 'module(name=\"flatbuffers\")\\n"
        "bazel_dep(name=\"rules_cc\", version=\"0.2.17\")\\n"
        "bazel_dep(name=\"platforms\", version=\"1.0.0\")\\n"
        "' > MODULE.bazel"
    ],
    strip_prefix = "flatbuffers-25.12.19",
    urls = ["https://github.com/google/flatbuffers/archive/v25.12.19.tar.gz"],
)

What’s happening here: the official registry (BCR) version of FlatBuffers pulls in a newer version of rules_foreign_cc that uses an API our Bazel version doesn’t support. The fix is archive_override — we download FlatBuffers’ source directly from GitHub, but we replace its MODULE.bazel with a minimal version that only declares the deps we know work.

The comment in the code explains why this override exists — essential for future maintainers who might be tempted to “clean up” the override and accidentally break the build.

Gazelle: Auto-Generating Go BUILD Files#

Go modules have their own dependency system (go.mod). Gazelle bridges Go’s world into Bazel’s:

1
bazel run //:gazelle -- update

This scans all .go files, reads their import statements, and generates or updates BUILD.bazel files automatically. When you add a new Go file that imports a new package, you run Gazelle instead of manually editing BUILD files.

The naming convention: github.com/foo/bar/baz becomes @com_github_foo_bar//baz in Bazel. Gazelle handles this translation.

Remote Caching: Share Build Results Across Machines#

1
2
3
4
# .bazelrc
build:buildbuddy --remote_cache=grpcs://remote.buildbuddy.io
build:buildbuddy --remote_executor=grpcs://remote.buildbuddy.io
build:buildbuddy --bes_results_url=https://app.buildbuddy.io/invocation/

Bazel’s caching is content-addressed: every build action is identified by a hash of its inputs (source files + compiler flags + dependencies). If any machine has already performed that exact action, the result is cached remotely.

Practical impact: Emscripten WASM compilation takes several minutes. With remote caching enabled, if your CI already built krag_chart_wasm from the same source, running bazel build //chart:krag_chart_wasm on your laptop takes seconds — it downloads the result instead of recompiling.

--bes_results_url streams build events to BuildBuddy’s web UI, where you can see per-action timing and cache hit/miss rates for every build.

The Day-to-Day Developer Experience#

1
2
3
4
bazel build //...               # build everything
bazel test //backend/...        # run Go tests
bazel run //backend:theron -- serve --collect   # run the backend binary
bazel run //trading-ui:dev      # start the React dev server (builds WASM first)

One tool, one command style, consistent across Go, C++, and TypeScript. When you change a C++ file in chart/src/, the next bazel run //trading-ui:dev automatically rebuilds the WASM module before starting Vite — Bazel knows about the dependency.

The cost: BUILD files need maintenance as code grows, and the learning curve for Bazel’s concepts is real. Worth it for a multi-language monorepo that’s meant to scale. Probably overkill for a single-language project.