Rush Octopus is the internal DevOps platform I built to stop treating version management, build orchestration, and deployment configuration as three separate problems. It’s a microservices API that provides a single source of truth for application lifecycle across multiple Git providers, CI systems, and artifact registries.

This is the story of the decisions behind it, the parts that were harder than expected, and what I’d change.

Vlad Khononov presents a modern perspective on coupling, arguing that it should be treated as a design tool rather than something to eliminate. The book introduces a multidimensional model of coupling and provides practical principles for building modular, evolvable, and resilient software systems across monoliths and distributed architectures.

The problem I was actually solving#

The starting point was mundane: a growing collection of services, each with slightly different deployment setups. Some apps were on GitHub, others GitLab. CI ran on GitHub Actions for newer things and Jenkins for anything older. Artifacts landed in AWS ECR or GCP Artifact Registry depending on which cloud the service deployed to.

None of that was catastrophic on its own. The problem was the accumulation. Answering "what version is running in staging right now?" meant cross-referencing three systems. Triggering a release meant knowing which CI system the specific service used, navigating there, and filling in inputs manually. Configuration — build commands, registry settings, Java version — lived in scattered wiki pages and shell scripts committed to individual repos.

The real cost wasn’t any single task. It was the context-switching tax and the mental model maintenance. Every engineer working across services had to track a slightly different operational picture for each one.

I wanted one API that could answer all of it and one pipeline that any service could use.

The integration model#

No matter the stack, the surface area for any application is identical: one HTTP call.

The application supplies three things: a version tag, a git ref, and its registered application ID. Rush Octopus resolves the repository metadata, build configuration, and target registries automatically. The caller doesn’t know or care which CI system runs the build, which cloud the image lands in, or how authentication works per registry.

That uniformity was the design goal. Onboarding a new service meant registering it once — git repo URL, build configuration, target registries — and from that point on, every release was POST /versions. Nothing per-project to maintain, no pipeline YAML to copy-paste, no registry credentials to rotate in each repo.

Starting with the domain#

Before writing a single Spring or Quarkus annotation, I built the domain model as a standalone library — lib-domain — with zero framework dependencies.

This wasn’t premature abstraction. At the time I was actively evaluating whether to use Spring Boot or Quarkus for some services. I knew that decision wasn’t settled. If the business logic was entangled with @Service and @Transactional, any framework switch would mean rewriting the core, not just the infrastructure wiring.

The domain layer defines the real objects: Version, Application, Build, AppConfiguration. These aren’t anemic data bags with a service class doing all the work. They own their behavior:

public class Version {
    public void generate() {
        validateStateForGeneration();
        createGitTag();
        updateStatusToQueued();
        triggerBuild();
    }

    public void retry() {
        if (status != Status.FAILED) throw new InvalidStateTransitionException(status, Status.QUEUED);
        this.status = Status.QUEUED;
        triggerBuild();
    }
}

When I need to test the version lifecycle, I test Version directly — no Spring context, no mocks for framework infrastructure, no @SpringBootTest overhead. The test is just Java. That alone makes iteration fast.

The tradeoff: this model adds setup cost upfront. You have to build the infrastructure adapters (persistence, HTTP clients) before anything runs end-to-end. For a solo project with uncertain requirements, that’s a real cost. In hindsight, the speed of testing and the clean framework migration path were worth it.

The architecture#

I split the platform into five services along domain boundaries, each with its own database. The split was driven by two things: different scaling profiles (builds are bursty, application registry is mostly read-heavy) and different storage characteristics (identity wanted DynamoDB for AWS-native auth integration, everything else was MongoDB).

Service Responsibility

rush-versions-api

Version lifecycle, Git tag creation, state machine

rush-applications-api

App registry, Git repository import, metadata

rush-builds-api

Pipeline triggering, build tracking, commit status reporting

rush-appconfigurations-api

Build configs, artifact registry settings

rush-identity-api

RBAC, user management, permission assignments

The services communicate via HTTP using OpenFeign clients. A BFF layer aggregates calls for the frontend, keeping the individual services focused on their bounded context. I consciously avoided shared databases — if two services need the same data, one of them owns it and the other calls the API.

The pattern I kept coming back to was: "would it make sense to deploy and scale these independently?" If yes, they’re separate. If it felt forced, I merged them.

The lazy-loading problem#

This was the hardest technical challenge. In a microservices setup, a Version object has a reference to its Application. But Application lives in a different service with its own database. How do you model that reference without turning every version.getApplication() call into an HTTP request?

With JPA/Hibernate, you’d get lazy-loading proxies for free. I deliberately chose not to bring in a JPA dependency — MongoDB doesn’t need it, and I didn’t want the domain model coupled to ORM concepts.

So I built lazy-loading proxies with ByteBuddy.

The concept is straightforward: store only the ID in the Version entity. When getApplication() is first called, intercept it, make the HTTP call to rush-applications-api, cache the result, and return it. Subsequent calls return the cached instance.

The implementation has real nuance. ByteBuddy generates a subclass at runtime that overrides the accessor methods. Wiring that into the deserialization pipeline (MongoDB documents → domain objects with lazy proxy fields) required careful ordering of how and when the proxy is created. Getting the generic type information right across the proxy boundary was the kind of problem that produces two-hour debugging sessions.

The payoff: call sites in the domain model look like plain Java. There’s no "is this loaded?" guard, no explicit fetch call, no leaked infrastructure concept. From the domain’s perspective, Application is just there.

Centralizing the CI/CD pipeline#

Early on, each service had its own GitHub Actions workflow, duplicating the same steps: checkout the repo, set up Java, build, dockerize, push to ECR or GCR. When a step needed changing — a new Java version, a different GraalVM flag — I’d update it in five places. The first time I missed one, I decided to centralize it.

rushassets-actions is a dedicated repository of reusable GitHub Actions that every service in the organization calls. It owns the complete build pipeline:

  • checkout-application — multi-provider checkout (GitHub, GitLab, Bitbucket) using the application’s stored provider metadata

  • docker-build-image — builds the container and outputs a tarball

  • push-registries — authenticates via OIDC/Workload Identity and pushes to whichever registries are configured

The build context (which registries, which build params, which Java version) comes from Rush Octopus itself, dispatched as a JSON payload to the pipeline. GitHub Actions is just the execution layer — the configuration lives in the platform.

The trickiest part was the webhook callback. After a build completes, GitHub sends a check_run webhook to rush-builds-api, which updates the build status and propagates that back to the version status. Getting the ordering right — the webhook arriving before or after the pipeline’s final cleanup step — required careful idempotency handling on the receive side.

Getting the state machine right#

I modeled version status and build status as separate state machines. They run in parallel but have independent transitions.

A version moves: PENDING → QUEUED → IN_PROGRESS → COMPLETED | FAILED | CANCELLED

A build moves: QUEUED → IN_PROGRESS → COMPLETED | FAILED | CANCELLED

The version becomes QUEUED when the Git tag is created. It becomes IN_PROGRESS when the pipeline starts (webhook from GitHub Actions). It moves to its terminal state when the build completes.

I kept the CANCELLED state because pipelines can be cancelled mid-run by a user or by a concurrency rule in GitHub Actions. Treating that as FAILED would have been wrong — a cancelled build shouldn’t block a retry.

FAILED versions can be retried. The retry resets the version to QUEUED and triggers the build pipeline again. This turned out to be important in practice: network failures in the pipeline (flaky registry auth, intermittent GitHub API timeouts) account for most failures. Being able to retry from the platform instead of re-running the action manually saves real time.

The state machine enforcement lives entirely in the domain entity. retry() on a non-FAILED version throws InvalidStateTransitionException. This means the API controller doesn’t need to check state — it delegates to the entity and handles the exception.

What I’d do differently#

Start smaller. The hexagonal architecture setup — ports, adapters, the full separation of domain from infrastructure — added meaningful overhead before anything was running end-to-end. For a solo project in the early phases, that tax was high. I’d probably start with a simpler layered structure and introduce the ports/adapters pattern only where the benefit was clear.

Consider event sourcing for version history. Version status changes are exactly the kind of data you want a full audit trail for. I’m storing the current status in MongoDB, which means "how did this version get to FAILED?" requires log digging. An append-only event log would have been better for both debugging and the UI.

Make the BFF a first-class citizen earlier. I added the BFF layer after most services were running. Retrofitting aggregation into an existing API surface is messier than designing it from the start.

Stack#

Layer Technology

Language

Java 21

Frameworks

Spring Boot 3.4 (primary) · Quarkus 3.17 (identity, bot)

Databases

MongoDB per service · DynamoDB (identity)

HTTP clients

OpenFeign

Object mapping

MapStruct

Proxy generation

ByteBuddy

CI/CD

GitHub Actions + rushassets-actions

Registries

AWS ECR · GCP Artifact Registry

Patterns

Hexagonal Architecture · DDD · CQRS · Rich Domain Model