Skip to content

How I Scaffolded an Entire Distributed Platform in 10 Minutes ​

Series: GraphQL Auth Explosion Case Study
Section: Developer Affordances

tl;dr: I spent a long time writing a scaffolding tool, then used it to make the boring first ten minutes of this experiment genuinely take ten minutes.

To explore GraphQL authorization in a service-oriented system, I needed more than a few isolated Rails applications. I needed a playground with separate services and databases, service discovery, messaging, observability, and enough environment configuration to run the whole thing consistently.

I already had Mobilis, a tool I wrote to scaffold service-based architectures. The script below describes the initial platform: which services exist, which infrastructure they depend on, and how they connect. Mobilis turned that description into the repository layout, Rails applications, Docker Compose configuration, environment files, and helper scripts.

The useful part was not that it "designed a distributed system" for me. It did not. It gave me a repeatable baseline so I could spend the next hour testing authorization behavior instead of copying Compose fragments, allocating ports, and bootstrapping the same Rails conventions five times.

The result is preserved as the initial generated codebase.

What the scaffold bought me ​

The generated repository started with several concrete affordances:

  • Each service had its own application and database configuration.
  • Docker Compose could start the platform as a connected system.
  • Ports were assigned consistently, so development, test, and production-shaped stacks could coexist without accidental conflicts.
  • Observability services were wired in from the beginning rather than postponed until debugging became painful.
  • Connections between applications and their dependencies were expressed once in the DSL and reflected in the generated environment.

None of these tasks is individually difficult. Together, they create enough friction that it is tempting to simplify an experiment until it no longer resembles the system being investigated. Scaffolding made the more representative experiment cheap enough to try.

Making Compose usable ​

One small feature did a disproportionate amount of work: the generated dc_dev, dc_test, and dc_prod scripts. Each script bundles the Compose files, overrides, and environment arguments for one environment. From the operator's perspective, a layered configuration behaves like a single compose.yml.

Underneath those commands, the repository has top-level Compose entry points assembled from per-service includes, plus development.env, test.env, and production.env. Top-level *-overrides.yml files hold the differences that cannot be shared cleanly. Rails' production database layout, for example, may require more database roles than the development setup.

That structure is more machinery than this particular experiment ultimately needed; most of the work happened in development. Its value was consistency. Once Mobilis knew how environments were composed, every generated platform got the same interface and I did not have to reconstruct the correct command each time.

What the scaffold did not buy me ​

Fast generation also made it cheap to encode questionable decisions. I chose LocalStack where GoAWS may have been a better fit, attached Redis only to the authorization service, and omitted a dedicated Groups service. Those were real modeling mistakes, not generator failures.

That distinction matters. A scaffold removes setup cost and makes conventions repeatable. It does not validate the architecture those conventions produce. In this project, the generated baseline was valuable precisely because it let me reach the interesting mistakes sooner, observe their consequences, and revise the system.

Here is the input that produced that baseline:

ruby
#!/usr/bin/env ruby

require "mobilis"

Mobilis::DSL.generate("parent_account_id") do
  # Infra
  redis("authcache")

  localstack("eventstream") do |svc|
    svc.sns_sqs("capability-changes", ["capability-changes"])
    svc.sns_sqs("group-membership-changes", ["group-membership-changes"])
    svc.sns_sqs("account-structure-changes", ["account-structure-changes"])
  end

  otel_collector("otel-collector")
  grafana("grafana")
  jaeger("jaeger")
  prometheus("prometheus")

  connect from: "grafana", to: "prometheus"
  connect from: "otel-collector", to: "jaeger"
  connect from: "prometheus", to: "otel-collector"

  # Services

  rails("user-service", primary_database: postgres("user-db"), api: true) do |svc|
    svc.install_graphql!
    svc.use_rspec!

    svc.write_file("app/models/user.rb", <<~USERMODEL)
      # frozen_string_literal: true

      class User < ApplicationRecord
      end
    USERMODEL

    svc.write_file("db/migrate/20250714003610_create_users.rb", <<~USERS)
      # frozen_string_literal: true
      class CreateUsers < ActiveRecord::Migration[8.0]
        def change
          create_table :users, id: :uuid do |t|
            t.uuid :account_id, index: true
            t.string :email
            t.string :username
            t.string :first_name
            t.string :last_name
            t.string :middle_name
            t.string :phone_number
            t.string :alt_phone
            t.string :slack_id
            t.string :avatar_url
            t.string :linkedin
            t.string :github
            t.string :twitter
            t.string :tshirt_size
            t.string :pronouns
            t.string :timezone
            t.timestamps
          end
        end
      end
    USERS
  end

  rails("account-service", primary_database: postgres("account-db"), api: true) do |svc|
    svc.install_graphql!
    svc.use_rspec!

    svc.write_file("db/migrate/20250714003609_create_accounts.rb", <<~ACCOUNTS)
      # frozen_string_literal: true
      class CreateAccounts < ActiveRecord::Migration[8.0]
        def change
          create_table :accounts, id: :uuid do |t|
            t.string :name
            t.uuid :parent_account_id, index: true

            t.timestamps
          end

          add_foreign_key :accounts, :accounts, column: :parent_account_id
        end
      end
    ACCOUNTS

    svc.write_file("app/models/account.rb", <<~ACCOUNTMODEL)
      # frozen_string_literal: true
      # app/models/account.rb

      class Account < ApplicationRecord
        belongs_to :parent_account, class_name: "Account", optional: true
        has_many :child_accounts, class_name: "Account", foreign_key: :parent_account_id, dependent: :nullify
        before_validation :assign_default_name, on: :create

        private

        def assign_default_name
          if name.blank?
            self.id ||= SecureRandom.uuid
            self.name = "Account \#{id}".truncate(36)
          end
        end
      end
    ACCOUNTMODEL
  end

  rails("authorization-service", primary_database: postgres("authz-db"), api: true) do |svc|
    svc.install_graphql!
    svc.use_rspec!

    svc.write_file("db/migrate/20250714003611_create_capabilities.rb", <<~CAPABILITIES)
      # frozen_string_literal: true
      class CreateCapabilities < ActiveRecord::Migration[8.0]
        def change
          create_table :capabilities, id: :uuid do |t|
            t.uuid :subject_id, index: true
            t.uuid :account_id, index: true
            t.string :permission
            t.timestamps
          end
        end
      end
    CAPABILITIES
  end

  connect from: "authorization-service", to: "authcache"

  rails("organization-service", primary_database: postgres("organization-db"), api: true) do |svc|
    svc.install_graphql!
  end

  rails("user-management-service") do |svc|
    svc.install_graphql!
    svc.use_tailwind!
    svc.use_rspec!
    svc.write_file("scripts/setup.sh", <<~SETUP)
      #!/bin/bash
      bundle add activeresource --require active_resource
    SETUP

    svc.write_file("scripts/create_user.rb", <<~USERMODEL)
      # frozen_string_literal: true

      # app/models/user.rb
      class User < ActiveResource::Base
        self.site = ENV.fetch("USER_API_BASE_URL") # e.g., http://user-service:3000/
        self.format = :json

        # Optional: if the resource uses UUIDs instead of integers
        self.primary_key = "id"

        # Optional: if user-service uses a different collection path
        self.collection_name = "users"

        # Optional: handle nested resources, errors, etc.
      end
    USERMODEL

    svc.write_file("scripts/create_user.rb", <<~CREATEUSER)
      User.create(name: "bleh")
    CREATEUSER
  end

  # OTEL wiring
  connect from: "user-service", to: "otel-collector"
  connect from: "account-service", to: "otel-collector"
  connect from: "authorization-service", to: "otel-collector"
  connect from: "user-management-service", to: "otel-collector"

  # user-management wiring

  connect from: "user-management-service", to: "user-service"
  connect from: "user-management-service", to: "account-service"
  connect from: "user-management-service", to: "authorization-service"
  connect from: "user-management-service", to: "organization-service"
end

The code block demonstrates the scaffold input, not the IAM model used by the case study. In the current system, capability_grants belong to Groups, users receive them through explicit Group memberships, and Group Service owns Groups and membership rows. Authorization Service evaluates those grants; the generated capabilities.subject_id table is not part of that path.