---
title: "rb-solar: a Rails application, generated"
description: "Ruby on Rails 8, SQLite, Hotwire: the Solar System API (Planet, Moon) and a human UI over the same data"
source: "https://aontu.dev/examples/rb-solar/"
---

# rb-solar: a Rails application, generated

Ruby on Rails 8, SQLite, Hotwire: the Solar System API (Planet, Moon) and a human UI over the same data

Held to [voxgig-sdk/voxgig-solardemo-sdk](https://github.com/voxgig-sdk/voxgig-solardemo-sdk), which supplies the OpenAPI description, the validation script and the Ruby SDK

Status nine checks, including the reference's own twenty tests

Rendered from [`test/system/rb-solar/README.md`](https://github.com/aontu-lang/aontu/blob/main/test/system/rb-solar/README.md) in the engine repository. The model, the generators, the committed application and the `check.sh` that renders and validates all of it are in [`test/system/rb-solar/`](https://github.com/aontu-lang/aontu/tree/main/test/system/rb-solar/).

The model's key tree

## Entities and relationships

The Rails app manages planets and their moons. It serves a JSON API and HTML pages over the same SQLite database. The seed data contains eight planets and twenty-one moons from the [Solar System reference API](https://github.com/voxgig-sdk/voxgig-solardemo-sdk).

| Entity | Fields | Relationship |
| --- | --- | --- |
| Planet | String `id`, `name`, `kind`, `diameter`, `terraform_state`, `forbid_state`, `forbid_reason` | Has zero or more moons |
| Moon | String `id`, `name`, `kind`, `diameter`, `planet_id` | Belongs to one planet |

Both entities use caller-supplied string IDs. Active Record validates the required fields. A moon requires a planet, and deleting a planet destroys its moons through the Active Record association.

Planet and Moon: database fields, primary keys, and foreign key

Mermaid source

```mermaid
%% Generated by aontu from model.aon. Do not edit.

erDiagram
  PLANET ||--o{ MOON : "moons"
  PLANET {
    float diameter
    string forbid_reason
    string forbid_state
    string id PK
    string kind
    string name
    string terraform_state
  }
  MOON {
    float diameter
    string id PK
    string kind
    string name
    string planet_id FK
  }
```

The ERD uses database column names. JSON responses use `terraformState`, `forbidState`, and `forbidReason` for the corresponding planet fields; `planet_id` keeps the same name in the database and API.

Learn [how aontu generates this ERD](https://aontu.dev/examples/rb-solar/erd), from the parent relationship and field flags to the Mermaid output.

## The Rails application

The browser pages list planets, show a planet, list its moons, and show a moon. These pages are read-only. The API provides create, read, update, and delete operations for both entities, plus the planet actions `forbid` and `terraform`.

| Interface | Routes | Response |
| --- | --- | --- |
| Browser | `/`, `/planets`, `/planets/:planet_id` | Planet list or details |
| Browser | `/planets/:planet_id/moons`, `/planets/:planet_id/moons/:moon_id` | A planet’s moon list or moon details |
| Planet API | `/api/planet`, `/api/planet/:planet_id` | JSON records |
| Moon API | `/api/planet/:planet_id/moon`, `/api/planet/:planet_id/moon/:moon_id` | JSON records scoped to a planet |
| Planet actions | `/api/planet/:planet_id/forbid`, `/api/planet/:planet_id/terraform` | JSON action result |

The API returns errors as `{error, message}` with status 400 for invalid input, 404 for a missing record, and 409 for a duplicate ID. Browser controllers return 404 when the requested record is absent.

## Application architecture

Routes select either a browser controller or an API controller. Both use the `Planet` and `Moon` Active Record models. Browser controllers pass records to ERB views; API controllers serialise records as JSON.

Rails application architecture: browser and API paths share the Active Record models

Mermaid source

```mermaid
flowchart TB
  browser["Browser"]
  client["API client or Ruby SDK"]
  routes["Rails router<br/>config/routes.rb"]
  ui["PlanetsController<br/>MoonsController"]
  api["Api::PlanetsController<br/>Api::MoonsController<br/>Api::BaseController"]
  views["ERB pages<br/>application layout"]
  json["JSON records<br/>or error responses"]
  models["Planet and Moon<br/>Active Record"]
  db[("SQLite<br/>planets and moons")]
  browser -->|"HTML request"| routes
  client -->|"API request"| routes
  routes --> ui
  routes --> api
  ui -->|"records"| models
  api -->|"read or write"| models
  ui -->|"render HTML"| views
  api -->|"serialise"| json
  models --> db
```

The API controllers inherit error handling from `Api::BaseController`. Browser controllers inherit from `ApplicationController` and use the shared application layout. The app has no separate service or repository layer: controllers call Active Record directly.

## Application layers

The layers describe the running Rails app. aontu is used to generate source files before the app runs; it is not part of request handling.

Rails layers: HTTP routing, controllers and views, models, and SQLite

Mermaid source

```mermaid
flowchart TB
  http["HTTP entry<br/>Puma and Rack: handwritten setup<br/>Rails routes: generated"]
  presentation["Request and presentation layer<br/>UI and API controllers: generated<br/>ERB pages: generated<br/>controller base and layout: handwritten"]
  domain["Domain and persistence layer<br/>Planet and Moon: generated<br/>validation and associations<br/>ApplicationRecord: handwritten"]
  storage[("Storage<br/>SQLite database<br/>migrations and seeds: generated<br/>database configuration: handwritten")]
  http --> presentation
  presentation --> domain
  domain --> storage
```

The generated controllers and models contain this example’s application behaviour. Rails supplies routing, view rendering, validation, and database access. Handwritten configuration sets up the runtime and shared UI layout.

## Folder structure and file ownership

Paths below are relative to the example’s `app/` directory. `[G]` marks files generated by aontu; `[H]` marks files maintained by hand, including Rails scaffold files. Directories can contain both kinds.

```tree
app/                                      Rails application root
├── Gemfile                               [H] dependencies
├── Gemfile.lock                          [H] the resolved gem set, pinned
├── Rakefile                              [H] Rails tasks
├── config.ru                             [H] Rack entry point
├── bin/                                  [H] Rails and development commands
├── config/
│   ├── routes.rb                         [G] browser and API routes
│   ├── application.rb                    [H] application configuration
│   ├── database.yml                      [H] SQLite configuration
│   ├── puma.rb                           [H] web server configuration
│   ├── boot.rb, environment.rb           [H] Rails startup
│   ├── environments/                     [H] per-environment settings
│   ├── initializers/                     [H] assets, logging, inflections
│   └── locales/                          [H] translations
├── app/
│   ├── controllers/
│   │   ├── application_controller.rb     [H] browser controller base
│   │   ├── planets_controller.rb         [G] planet HTML pages
│   │   ├── moons_controller.rb           [G] moon HTML pages
│   │   └── api/
│   │       ├── base_controller.rb        [G] API error responses
│   │       ├── planets_controller.rb     [G] planet CRUD and actions
│   │       └── moons_controller.rb       [G] moon CRUD
│   ├── models/
│   │   ├── application_record.rb         [H] Active Record base
│   │   ├── planet.rb                     [G] fields and moon association
│   │   └── moon.rb                       [G] fields and planet association
│   ├── helpers/application_helper.rb     [H] shared view helper
│   └── views/
│       ├── layouts/application.html.erb  [H] shared page layout
│       ├── planets/index.html.erb        [G] planet list
│       ├── planets/show.html.erb         [G] planet details
│       ├── moons/index.html.erb          [G] moon list
│       └── moons/show.html.erb            [G] moon details
└── db/
    ├── migrate/*_create_planets.rb        [G] planet table
    ├── migrate/*_create_moons.rb          [G] moon table
    └── seeds.rb                          [G] reference planet and moon data
```

Generated files carry a banner naming aontu and `model.aon`. The byte gate compares them with the generator output and reports manual changes as drift. It does not manage the handwritten files. Database contents, logs, and temporary runtime files are not generated application source and are omitted from this tree.

The surrounding example directory contains the inputs and checks:

```tree
rb-solar/
├── app/          Rails app: mixed ownership, detailed above
├── model.aon     Handwritten service, entity, field, and action definitions
├── gen/          Nine handwritten generators; the diagram's in gen/doc/
├── ref/          Pinned API reference, seed data, and validation clients
├── doc/          Generated ERD and model views; authored architecture diagrams
└── check.sh      Generation checks and live application checks
```

## Running it

```sh
cd app && bundle install     # once
cd .. && ./check.sh
```

`check.sh` skips the server checks with a note when Ruby or the bundle is absent, so the generation checks can still run. It honours `$AONTU`, so the Go port runs the same check, and `RB_SOLAR_PORT` when 8901 is taken.

To drive the app by hand:

```sh
cd app
bin/rails db:reset
bin/rails server -p 8901
```

Then [http://localhost:8901/](http://localhost:8901/) for the pages and [http://localhost:8901/api/planet](http://localhost:8901/api/planet) for the API.

## What it is held to

`ref/` carries the reference verbatim at one commit and nothing there is edited: see [`test/system/rb-solar/ref/README.md`](https://github.com/aontu-lang/aontu/blob/main/test/system/rb-solar/ref/README.md), which also records the one place the OpenAPI description and the executable validation disagree, and why the executable one wins.

`check.sh` runs nine checks, including two against the reference API:

-   **`ref/validate.ts`**, the reference repository’s own script, unmodified. It sends HTTP requests to the Rails app. All twenty of its tests pass, cascade delete and error envelope included.
-   **the reference’s Ruby SDK**, driving the app through its real client, in [`test/system/rb-solar/ref/sdk_live.rb`](https://github.com/aontu-lang/aontu/blob/main/test/system/rb-solar/ref/sdk_live.rb): thirteen assertions against the running server. The check exits 1 if no server is available.

## The model

[`test/system/rb-solar/model.aon`](https://github.com/aontu-lang/aontu/blob/main/test/system/rb-solar/model.aon) is the whole input. It states the service, two entities with their fields and URL shapes, the two planet actions, the error envelope, and it **includes the reference’s seed data as data** rather than copying it, so the rows this app serves and the rows the reference serves cannot drift apart.

Seven things in it are worth reading for the reasons behind them:

-   **The named collections are maps, keyed by their own names.** The entities, an entity’s fields, its actions and the error envelope are all maps: `$.entity.planet.field.diameter` addresses a field, `parent: "planet"` on the moon names a key that exists, and a new one anywhere is an insertion rather than a position. A list would have given each of them a number nobody chose, that a reader has to count to and that moves when something is inserted in front of it.
-   **Declare any required order in the model.** A map is walked in sorted-key order, so the order that is on the page is stated rather than smuggled in: `sequence` lists the two entities in the order the migrations create them and the seeds insert them, because a moon keys into a planet, and an action’s `rule` stays a list for the reason set out below. Five of the nine generators write one file per entity and never see an order at all.
-   **Every field states its JSON name.** The wire says `terraformState` where the column says `terraform_state`, and `planet_id` either way. What a field is called in a target is a fact about the model, not a rule in a template; a generator that derived one from the other would be guessing, and would be wrong three times out of seven here.
-   **Every field answers `pk` and `fk`, including with `false`.** Rule tables need an explicit boolean to select fields by these flags.
-   **An index page’s key column is asked for, not assumed.** A table’s header row and its body row are two dispatches over the same fields, and the body writes the key’s cell itself because that one is a link. While `field` was a list with `id` written first, one loop over the fields put the key column first in both rows by luck; a map sorts, and every planet’s `diameter` came out under a header reading `id`. Both rows now name the key first, and `check.sh` reads the live page and asks what is under `name`.
-   **An action’s rules are listed lowest priority first.** The generated form is a sequence of assignments and the last one wins, so this reproduces the reference’s `if`/`elsif`: `{start: true, stop: true}` is terraforming, not idle: a case the reference’s own validation never exercises.
-   **Everything a generated line needs is on the node its rule matched.** An entity’s indexes and seed rows are stated under the entity, each row carrying its own table or class, because a nested dispatch cannot see the node the enclosing one matched.

Follow the [model guide](https://aontu.dev/examples/rb-solar/model) to inspect these values and see how generators select them.

## The generators

Nine of them, in [`test/system/rb-solar/gen/`](https://github.com/aontu-lang/aontu/blob/main/test/system/rb-solar/gen/): the eight that write `app/` directly in it, so one `aontu render gen app` writes them, and the diagram generator in [`test/system/rb-solar/gen/doc/`](https://github.com/aontu-lang/aontu/blob/main/test/system/rb-solar/gen/doc/), since it writes `doc/`. Eight are **files in the language they generate**: a marked line carries the aontu and the target’s own tools read the rest, so `ruby -c` parses the seven Ruby ones and a Mermaid renderer draws the diagram:

| generator | writes |
| --- | --- |
| `routes.rb` | `config/routes.rb`, the UI and API routes |
| `migrate.rb` | one migration per entity, columns in model order |
| `seeds.rb` | the reference’s eight planets and twenty-one moons |
| `model.rb` | the Active Record classes, associations, cascade, validations |
| `api_base.rb` | the `{error, message}` envelope, one method per error |
| `api_controller.rb` | the five verbs, the actions, the serialiser, the parent scope |
| `ui_controller.rb` | the page controllers |
| `erd.mmd` | the ER diagram: a Mermaid file whose marker is `%%-` |
| `views.aon` | the four ERB pages, in canonical aontu |

`views.aon` is the exception, for a reason in ERB rather than here: ERB’s only comment is `<%# … %>`, a delimited form closing `%>`, and the template surface’s block marker is fixed to the C family (`/*-` … `*/`). No marker an ERB file can carry is one ERB itself ignores, so a generator written as an `.html.erb` file could not stay valid in its own language, which is the whole promise.

`erd.mmd` shows the other side of that: Mermaid is not in the marker table, and one `--marker '%%-'` is all it costs.

All nine are in the form `aontu fmt` writes, and `check.sh` says so. A generator is two documents on one page (the target’s, in its own lines, and aontu’s, in the marker lines) and only the first one used to have a shape you could read. The marker now stands at the left margin with the aontu indented after it, so the tree is visible as a tree:

```rb
#-   emit([$.service.root], {
#-     match: string
#-     replace: ROOT: _
#-     body: [
  root "ROOT"
#-     ]
#-   })
```

Nothing in this moves a line of the generated app: the byte gate compares byte for byte, and the first check in `check.sh` is that one.

The [Rails template guide](https://aontu.dev/examples/rb-solar/rails-code) follows a service value into a route and required fields into Active Record validations.

## How the app is generated

One model, nine generators, one tree. The byte gate compares what the generators would write against what is committed, so the arrow back is a gate rather than a suggestion.

Mermaid source

```mermaid
flowchart LR
  ref[("the reference<br/>voxgig-solardemo-sdk")]
  model["model.aon<br/>service · 2 entities · 2 actions<br/>error envelope · seed data"]
  ref -->|"seed rows included as data,<br/>never copied"| model

  subgraph gen["gen/: nine generators"]
    rb["seven Ruby files<br/>routes · migrate · seeds · model<br/>api_base · api_controller · ui_controller"]
    mmd["erd.mmd<br/>marker %%-"]
    views["views.aon<br/>the four ERB pages"]
  end
  model --> gen

  app[("app/<br/>the committed Rails tree")]
  doc[("doc/<br/>ERD, trees, lattice")]
  gen -->|"aontu answers the tree,<br/>a generator runtime writes the bytes"| app
  gen --> doc

  check{{"the byte gate<br/>nine checks in check.sh"}}
  app --> check
  check -->|"a hand edit to a generated<br/>file is reported as drift"| app
```

## Changing the application

Choose where to make a change according to who owns the file. Generated files come from the model and generators. Handwritten files are maintained as ordinary Rails code.

Follow [change and check the generated app](https://aontu.dev/examples/rb-solar/change-and-check) to add a field, regenerate the outputs, and check the resulting diff.

### Where to edit

| you are changing | edit | what holds it |
| --- | --- | --- |
| a **fact about the system**: a field, an entity, a route shape, an action’s rules, a seed row | `model.aon` | the model is the only statement of it; every target follows |
| **how a fact becomes code**: the shape of a controller, the columns a migration writes | the generator in `gen/` | it stays a valid file in its own language, so `ruby -c` still parses it |
| **anything the model does not decide**: a Gemfile, an initialiser, a background job, a service object, a bespoke query | the hand-written set | nothing; it is ordinary Ruby, reviewed like ordinary Ruby |

The boundary is not a convention anyone has to remember. Every generated file opens with

```ruby
# Generated by aontu from model.aon. Do not edit.
```

and the byte gate compares the whole tree against what the generators would write. A hand edit to a generated file is drift, and the check names the file.

### Extending generated code

The interesting case is not the one the rules cover. It is the day someone needs a generated file to do something the model has no way to say. Choose how to extend the generated application:

1.  **Lift it into the model.** The change is a fact about the system, so state it once and let every target that cares consume it. This is right when the same fact would otherwise be repeated by hand in the API controller, the UI controller and the ERD.
2.  **Teach the generator.** The change is about how a fact becomes code, not about the system. The model does not move; one rule does.
3.  **Move the file out.** Delete its rule, drop the banner, and it becomes an ordinary hand-written file. This is a real option and sometimes the right one: a controller that has grown genuinely bespoke logic is no longer a consequence of the model, and pretending otherwise makes the generator worse for everything else.

What is **not** an answer is editing the generated file and leaving it. That is the state the check exists to make visible in the next check.

### Checking agent changes

An agent can edit a generated file without updating its model or generator. Run the checks to detect that mismatch:

-   **Changed generated files are identified.** The byte gate answers, in one command and over the whole tree, whether an edit landed on a file the model owns. No reviewer has to hold the generated set in their head.
-   **The fix is mechanical.** Drift on a generated file means one of the three answers above, and the diff shows which: a hand edit that the generator would also have written is a model change waiting to be named; one it would not is a bespoke change that has to move out.
-   **The API behaviour is tested.** The nine checks end with other people’s code: the reference’s twenty validation tests and its own SDK, driving the running app. A change can satisfy every structural check and still be wrong, and those legs are what say so.

The generated half and the hand-written half are reviewed differently on purpose. A diff to `app/` under a generated banner should be read as a diff to `model.aon` or to `gen/`, because that is what caused it, and the check will not let it be anything else.

## Diagram sources

The [ERD](https://github.com/aontu-lang/aontu/blob/main/test/system/rb-solar/doc/erd.mmd) is generated from the same entity definitions as the Rails models. The model tree, planet tree, and value lattice are produced by `aontu view` and checked against committed text and SVG files. The [application architecture](https://github.com/aontu-lang/aontu/blob/main/test/system/rb-solar/doc/architecture.mmd) and [layer diagram](https://github.com/aontu-lang/aontu/blob/main/test/system/rb-solar/doc/layers.mmd) describe the committed Rails source.

## Learn to use aontu with this example

-   [Read the application model](https://aontu.dev/examples/rb-solar/model). Set up the CLI and inspect fields, associations, ordering, and seed data.
-   [Generate Rails code](https://aontu.dev/examples/rb-solar/rails-code). Follow template markers, selections, replacements, and output paths.
-   [Generate the ERD](https://aontu.dev/examples/rb-solar/erd). Trace entity relationships and field flags into Mermaid source and the displayed diagram.
-   [Change and check the app](https://aontu.dev/examples/rb-solar/change-and-check). Add an optional field, regenerate the targets, and verify the application.
