Runway is the official ORM (Object-Record Mapping) framework for Concourse. It provides a framework for persisting simple POJO-like objects to Concourse while automatically preserving transactional security, enforcing constraints, and managing record relationships.
Use the Runway controller to connect to a Concourse database. The simplest approach uses default connection parameters (localhost:1717, admin/admin):
Runway db = Runway.connect();For more control, use the builder:
Runway db = Runway.builder()
.host("db.example.com")
.port(1717)
.username("admin")
.password("secret")
.environment("production")
.build();Runway implements AutoCloseable, so it can be used in a try-with-resources block:
try (Runway db = Runway.builder().build()) {
// work with db
}When only a single Runway instance exists, it is automatically "pinned" so that Records can call save() on themselves without an explicit reference. If multiple instances exist, you must save Records through the Runway controller directly using db.save(record).
Persistable types extend the Record class. Like Concourse itself, Runway does not require an explicit schema. The class definition and optional annotations are all that is needed.
public class Player extends Record {
public String name;
@Unique
@Required
protected String email;
private int score = 0;
}- Non-transient fields are persisted to the database. Mark fields
transientto exclude them. - Field visibility controls serialization output. Public and protected fields appear in
json()andmap()output. Private fields are stored but excluded from serialized output unless annotated with@Readable. - Java inheritance is fully supported. Fields from superclasses are inherited by subclasses.
Every Record has a unique id assigned automatically when it is first created. Access it via record.id().
Runway intelligently maps Java types to Concourse storage:
| Java Type | Concourse Storage |
|---|---|
| Primitives, Strings | Stored directly as the corresponding type |
Record subclasses |
Stored as Links between records |
| Collections, Arrays | Each element stored individually for the key |
Serializable |
Stored in serialized (binary) form |
Enum |
Stored as the enum constant's Tag representation |
Annotate fields to declare database constraints that Runway enforces on save:
| Annotation | Effect |
|---|---|
@Required |
Rejects save if the value is null or an empty collection/array |
@Unique |
Rejects save if another record of the same class has the same value |
@ValidatedBy |
Rejects save if the value fails the specified Validator |
Apply the same @Unique constraint across multiple fields by giving them the same name. This enforces that the combination of values is unique, rather than each field independently:
@Unique(name = "location")
public String city;
@Unique(name = "location")
public String state;Create a Record by calling its constructor. No database interaction occurs until the record is saved.
Player player = new Player();
player.name = "Serena Williams";
player.email = "serena@example.com";Save a Record to persist its current state to Concourse. Runway calculates diffs and stores only the changes within an ACID transaction.
boolean success = player.save();If constraints are violated, save() returns false. Call throwSuppressedExceptions() to get a detailed stack trace of the failure.
Save multiple records in a single ACID transaction via the Runway controller. This is essential when records reference each other:
Player player1 = new Player();
Player player2 = new Player();
player1.rival = player2;
db.save(player1, player2);Load a single Record by its class and id:
Player player = db.load(Player.class, 42);Load all Records of a type:
Set<Player> allPlayers = db.load(Player.class);Use Concourse Criteria to query for matching Records:
Set<Player> found = db.find(Player.class,
Criteria.where().key("score")
.operator(Operator.GREATER_THAN)
.value(100).build());When you expect exactly one result:
Player player = db.findUnique(Player.class,
Criteria.where().key("email")
.operator(Operator.EQUALS)
.value("serena@example.com").build());Methods ending in Any search across a type hierarchy. For example, if ProPlayer and AmateurPlayer both extend Player:
// Only loads exact Player instances
Set<Player> exact = db.load(Player.class);
// Loads Player, ProPlayer, AmateurPlayer, etc.
Set<Player> all = db.loadAny(Player.class);The same pattern applies to find/findAny, findUnique/findAnyUnique, count/countAny, and search/searchAny.
Pass Order and Page to control result ordering and pagination:
Set<Player> topTen = db.load(Player.class,
Order.by("score").descending(),
Page.sized(10).go(1));Count records matching criteria without loading them:
int total = db.count(Player.class);
int highScorers = db.count(Player.class,
Criteria.where().key("score")
.operator(Operator.GREATER_THAN)
.value(100).build());Mark a record for deletion, then save to commit:
player.deleteOnSave();
player.save();Use get to read fields by name and set to write them:
Map<String, Object> data = player.get("name", "score");
player.set("score", 99);
player.set(Map.of("name", "New Name", "score", 100));Fields whose type is another Record subclass are automatically stored as Links in Concourse. When a Record is loaded, its linked Records are loaded too.
public class Team extends Record {
public String name;
public Set<Player> roster = new LinkedHashSet<>();
}Use DeferredReference<T> for lazy loading. The linked Record is only loaded from the database when get() is called, improving performance for large object graphs:
public class Team extends Record {
public DeferredReference<Coach> coach;
}
// Later
Coach coach = team.coach.get();Annotations control what happens to linked Records when a Record is deleted:
| Annotation | Behavior |
|---|---|
@CascadeDelete |
Deleting this record also deletes the linked record |
@JoinDelete |
Deleting the linked record also deletes this record |
@CaptureDelete |
Deleting the linked record nullifies the reference |
public class Blog extends Record {
@CascadeDelete
public Set<Comment> comments; // Deleting blog deletes comments
@JoinDelete
public Author author; // Deleting author deletes this blog
@CaptureDelete
public Category category; // Deleting category sets this to null
}Add virtual properties to Records that are included in json() and map() output but are not stored in the database.
Derived properties are lightweight, cached computations based on intrinsic fields:
public class Player extends Record {
public String firstName;
public String lastName;
@Derived
public String fullName() {
return firstName + " " + lastName;
}
}Computed properties are recalculated on every access and are never cached. Use them for expensive or time-sensitive computations:
public class Player extends Record {
@Computed
public int ranking() {
// Expensive calculation that should always be fresh
return calculateGlobalRanking();
}
}Both annotations accept an optional value parameter to customize the property name:
@Derived("full_name")
public String fullName() { ... }Realms provide logical data segregation within a single Concourse environment. A Record can belong to multiple realms simultaneously, and records with no realm assignment are visible in all realms.
player.addRealm("east-coast");
player.addRealm("all-star");
player.removeRealm("east-coast");
Set<String> realms = player.realms();Pass a Realms matcher to any query method:
// Only load players in the "east-coast" realm
Set<Player> eastern = db.load(Player.class,
Realms.only("east-coast"));
// Load players in either realm
Set<Player> selected = db.load(Player.class,
Realms.anyOf("east-coast", "west-coast"));
// Load from all realms (default behavior)
Set<Player> everyone = db.load(Player.class, Realms.any());Runway includes a built-in access control framework that governs how different users (called Audiences) interact with Records. This is activated by having a Record implement the AccessControl interface and having your user type implement the Audience interface.
public class Document extends Record implements AccessControl {
public String title;
private String content;
private Player owner;
@Override
public boolean $isCreatableBy(Audience audience) {
return true; // Any authenticated user can create
}
@Override
public boolean $isCreatableByAnonymous() {
return false;
}
@Override
public boolean $isDiscoverableBy(Audience audience) {
return true; // Anyone can see it exists
}
@Override
public boolean $isDiscoverableByAnonymous() {
return true;
}
@Override
public boolean $isDeletableBy(Audience audience) {
return audience.equals(owner);
}
@Override
public Set<String> $readableBy(Audience audience) {
if(audience.equals(owner)) {
return ALL_KEYS; // Owner reads everything
}
else {
return ImmutableSet.of("title"); // Others see title only
}
}
@Override
public Set<String> $readableByAnonymous() {
return ImmutableSet.of("title");
}
@Override
public Set<String> $writableBy(Audience audience) {
if(audience.equals(owner)) {
return ALL_KEYS;
}
else {
return NO_KEYS;
}
}
@Override
public Set<String> $writableByAnonymous() {
return NO_KEYS;
}
}An Audience is a Record that can perform operations on other records, subject to access rules. Implement the Audience interface on a Record type:
public class User extends Record implements Audience {
public String name;
@Unique
@Required
protected String email;
}Once configured, database operations routed through an Audience automatically enforce the access rules:
User user = db.load(User.class, userId);
// Load — returns null if user can't discover the document
Document doc = user.load(Document.class, docId);
// Find — only returns documents visible to the user
Set<Document> docs = user.find(Document.class, criteria);
// Read — throws RestrictedAccessException if denied
Object title = user.read("title", doc);
// Write — throws RestrictedAccessException if denied
user.write("title", "New Title", doc);
// Frame — returns only the fields the user can see (no exception)
Map<String, Object> data = user.frame(doc);
// Create — throws RestrictedAccessException if denied
Document newDoc = user.create(Document.class);
// Delete — throws RestrictedAccessException if denied
user.delete(doc);Access-controlled operations can also be invoked from the record's perspective:
doc.readAs(user, "title");
doc.writeAs(user, "content", "Updated content");
doc.frameAs(user);
doc.deleteAs(user);For unauthenticated contexts, use the anonymous audience:
Audience anon = Audience.anonymous();
Set<Document> publicDocs = anon.find(Document.class, criteria);| Constant | Meaning |
|---|---|
ALL_KEYS |
Access to every field on the record |
NO_KEYS |
No access to any field |
You can also return a specific set of field names, or use negative rules (prefix with -) to deny specific fields while allowing all others.
Implement the Metadata interface on a Record to gain computed temporal properties:
public class Player extends Record implements Metadata {
public String name;
}
// After saving and reloading
Timestamp created = player.createdAt();
Timestamp updated = player.lastUpdatedAt();
Timestamp nameUpdated = player.lastUpdatedAt("name");These properties are @Computed and are never cached — they query the audit log on every access.
Records provide json() and map() methods for serialization.
String json = player.json();
String partial = player.json("name", "score");Map<String, Object> data = player.map();
Map<String, Object> partial = player.map("name", "score");Customize serialization behavior with SerializationOptions:
SerializationOptions options = SerializationOptions.builder()
.flattenSingleElementCollections(true)
.serializeNullValues(true)
.build();
String json = player.json(options);
Map<String, Object> data = player.map(options, "name", "score");| Modifier | Stored in DB | Appears in json()/map() |
|---|---|---|
public |
Yes | Yes |
protected |
Yes | Yes |
private |
Yes | No (unless @Readable) |
transient |
No | No |
Override beforeSave() in your Record subclass to perform logic before data is persisted. This is useful for computing derived values, validating business rules, or setting defaults:
public class Player extends Record {
public String name;
public String nameNormalized;
@Override
protected void beforeSave() {
nameNormalized = name.toLowerCase().trim();
}
}Register asynchronous listeners on the Runway builder that fire after a Record is successfully saved:
Runway db = Runway.builder()
.onSave(Player.class, player -> {
System.out.println("Player saved: " + player.id());
})
.onSave(Record.class, record -> {
// Fires for ALL record types
audit(record);
})
.build();Listeners are type-filtered (including subclasses), compositional (multiple listeners can be registered), and execute asynchronously in a dedicated thread.
Mark a private field as @Readable to include it in json(), map(), and get() output while keeping the field encapsulated:
public class User extends Record {
@Readable
private Timestamp joinDate;
private String passwordHash; // Not readable, truly private
}Register a handler for load failures via the builder:
Runway db = Runway.builder()
.onLoadFailure((clazz, recordId, error) -> {
logger.error("Failed to load {} #{}: {}",
clazz.getSimpleName(), recordId, error);
})
.build();| Feature | Mechanism |
|---|---|
| Schema definition | Non-transient member variables |
| Constraints | @Required, @Unique, @ValidatedBy |
| Record linking | Record-typed fields, DeferredReference |
| Delete propagation | @CascadeDelete, @JoinDelete, @CaptureDelete |
| Virtual properties | @Derived, @Computed |
| Multi-tenancy | Realms |
| Access control | AccessControl + Audience |
| Temporal metadata | Metadata interface |
| Serialization | json(), map(), SerializationOptions |
| Lifecycle hooks | beforeSave(), onSave() listeners |
| Field visibility | Access modifiers, @Readable |