Composable applications#
Action Controller 9.0.0 lets a Spider-Gazelle application reuse another app's controllers, serve several apps from one process, and mount an app at a new public path. The same composition describes HTTP routes, OpenAPI operations and MCP tools. Existing apps can keep their template startup and automatic controller discovery.
An application boundary is a controller base class, often an abstract App::Base.
Selecting that base selects its concrete descendants and their routes. Each app can
keep its own inherited filters, responders and exception handlers.
Choose how to combine apps#
For a combined API with one OpenAPI document and one global MCP server, start with
AC::Server.compose. Add mounts when you need to change public paths. Use a handler
chain when ordered fallback between independent routers is part of the design.
| Option | Useful for | Upsides | Tradeoffs |
|---|---|---|---|
| Automatic discovery | An existing single app | Existing startup and catalog calls keep working | Requiring a controller library can expose more routes than intended; application roots are implicit |
AC::Server.compose(...) |
One combined server using the template | Explicit roots, one routing table, unified OpenAPI/MCP and CLI output | Declared once per compiled application; conflicting public operations must be resolved |
AC::Composition.new(...) |
Several server instances or different catalogs in one binary | Each instance selects its own roots and public placements | Pass the composition to servers, generators and tests; it does not configure the template's default CLI output |
App::Base.handler in an HTTP handler chain |
Ordered fallback, overlapping local routes, or integration with other Crystal handlers | Each app matches independently; the first match wins | Each miss tries another router; a raw chain does not automatically produce a shared catalog or global MCP server |
Mounting is available within these compositions. A mount changes where a controller subtree is exposed. It gives reusable apps stable internal definitions and host-chosen public URLs, but callers must use mount-aware URL helpers and the host must coordinate middleware and global configuration.
Reuse controllers as a library#
A reusable app should expose a library entry point that requires its controller
base, controllers and supporting models. For example, a shard might provide
require "other_app/controllers", defining OtherAC::App::Base and its descendants.
Keep executable startup and global configuration in the standalone app's entry point. The host requires the controller library and decides which server to create, which middleware to install, and how to configure sessions and MCP. Requiring a library should not also start a server or replace those settings.
You can continue to run the reusable app standalone with its existing template. Compilation includes the controllers; each composition selects the subtree it serves at initialization.
One combined server#
In the host's src/config.cr, require both apps' controllers, then declare their
roots:
require "action-controller"
require "./controllers/application"
require "./controllers/*"
require "other_app/controllers" # your reusable shard's entry point
require "action-controller/server"
require "action-controller/mcp"
AC::Server.compose(App::Base, OtherAC::App::Base)
compose is a compile-time declaration. Declare it once, after requiring the
controller definitions. It selects the default composition used by AC::Server,
OpenAPI, MCP and the spec helper. The server registers all selected placements in
one routing table; it does not try a separate app router for each request.
The template's existing server startup can stay as it is:
server = AC::Server.new(port, host)
AC::MCPServer.mount(server, "/mcp")
server.run
Its command line options also use the selected roots:
./bin/app --routes
./bin/app --docs --file=openapi.yml
./bin/app --mcp=mcp.yml
Although the template parses these options before config.cr is initialized, the
compiler has already processed the compose declaration. Catalog generation can
therefore exit before database connections or other configuration side effects.
If you omit compose, existing automatic discovery continues. Controllers used
only as mount targets are excluded from standalone automatic discovery, so their
original routes are not also exposed accidentally. Explicitly selecting an app
includes its subtree independently of mounts declared by unrelated apps.
Mount at a new base path#
Suppose a reusable library defines this controller:
module OtherAC
module App
abstract class Base < AC::Base
base "/oauth2"
end
class OAuth2 < Base
base "/oauth2"
@[AC::Route::GET("/login")]
def login : String
"Sign in"
end
@[AC::Route::POST("/token")]
def token : String
"Issue a token"
end
end
end
end
The host chooses its public location:
class MyApp < AC::Base
base "/myapp/"
mount "/auth/", OtherAC::App::OAuth2
end
AC::Server.compose(MyApp)
The mount replaces the target's base. The routes become
GET /myapp/auth/login and POST /myapp/auth/token; the /oauth2 base is removed.
Slashes are normalized. The mount declaration belongs to the host controller and
its path is relative to that controller's public base.
A target can be a concrete controller or an abstract application base. Mounting an app includes its descendants, preserving their paths relative to the app's base:
| Original definition | Mounted at /service |
|---|---|
App base /api |
/service |
Descendant controller /api/users |
/service/users |
Descendant controller /health |
/service/health |
A descendant outside the app's base retains its entire path below the mount.
base still sets each controller's own path; it does not implicitly prefix
descendant controller bases.
Mounts can be nested and repeated:
class Gateway < AC::Base
base "/gateway"
mount "/primary", MyApp
mount "/secondary", MyApp
end
Selecting Gateway exposes the login action at both
/gateway/primary/auth/login and /gateway/secondary/auth/login. Each placement
has its own public path, while the controller definition remains reusable.
Targets can be named relative to the declaring controller's namespace and can be declared later in the source. Mount cycles, conflicting public operations and ambiguous path parameter names are rejected. Conflict checks include equivalent parameterized paths and GET's generated HEAD operation. Mounting is not a way to choose an order between conflicting routes; use distinct bases or a handler chain when ordered fallback is required.
Filters and request paths#
Mounted actions run the target controller's own filters, responders and exception handlers, including those inherited from its controller base. The mounting controller's filters apply to its own actions. Mounting another subtree does not make its controllers inherit the host's filters.
Put cross-app policy in shared middleware or a controller base that the apps actually inherit. The host owns global logging, compression, sessions, MCP authentication/configuration and UI asset locations. See filters and configuration.
Requests keep their public request.path; mounting does not strip a prefix or
rewrite the request. The controller sees its public base through the instance
base_route. Explicit compositions and mounted apps isolate matched routes from
path bindings left by upstream handlers. A miss preserves the context for the
next handler. Ordinary automatic routing retains its existing binding behavior.
Parameterized mounts#
A mount can add parameters that actions or filters previously received from the query string:
class Reports < AC::Base
base "/reports"
@[AC::Route::GET("/summary")]
def summary(account_id : Int64) : String
"Report for account #{account_id}"
end
end
class Accounts < AC::Base
base "/accounts"
mount "/:account_id/reports", Reports
end
Selecting Accounts exposes GET /accounts/42/reports/summary. The bound
account_id is converted to Int64 for the action, and OpenAPI and MCP describe
the public parameter.
Required parameters in a target's original path must remain required parameters in the replacement path, with the same names. A mount cannot remove a required parameter or make it optional. If a replaced base contained optional parameters, declared action arguments can fall back to query parameters.
Optional mount segments (?:name) preserve the action's argument requirements:
an argument required by the action remains required when the segment is omitted,
so the caller must provide it through its query form. At a parameterized MCP
controller endpoint, only arguments bound by the current session URL are removed
from that session's tool schema. Other arguments remain available, and bound URL
values take precedence when tools or prompts are invoked.
Generate URLs for a placement#
Inside an action, use route_path to build a URL with the current mounted base and
bound path parameters:
route_path(:login) # inside the mounted OAuth2 controller
# => "/myapp/auth/login"
You can use that result with redirect_to. Explicit helper arguments override
bound values, for example route_path(:summary, account_id: 7). Individual path
segments are encoded; optional and glob segments are supported. Later optional
segments require values for earlier optional segments.
Existing class helpers such as OtherAC::App::OAuth2.login still produce the
original /oauth2/login URL. Outside a request, use a composition to choose the
public placement:
composition = AC::Composition.new([MyApp.name])
composition.url_for(OtherAC::App::OAuth2, :login)
# => "/myapp/auth/login"
gateway = AC::Composition.new([Gateway.name])
gateway.url_for(
OtherAC::App::OAuth2, :login,
mount_base: "/gateway/secondary/auth",
)
# => "/gateway/secondary/auth/login"
If a controller has several placements, url_for requires mount_base: to select
one. For parameterized mounts, mount_base: is the placement's path pattern;
pass the concrete parameter values as helper arguments.
Independent compositions and unified catalogs#
Create composition instances when different servers or generated catalogs need
different selections. Roots are class names here; Server.compose takes class
constants.
# After requiring the controller libraries
require "action-controller/server"
require "action-controller/mcp"
composition = AC::Composition.new([MyApp.name, AnotherApp::Base.name])
server = AC::Server.new(composition: composition)
AC::MCPServer.mount(server, "/mcp")
docs = AC::OpenAPI.generate_open_api_docs(
title: "Combined API", version: "1.0.0", composition: composition,
)
File.write("openapi.yml", docs.to_yaml)
AC::MCPServer.write_description("mcp.yml", composition: composition)
The server's global MCP endpoint infers its composition from the server. OpenAPI and description generation receive the same instance explicitly. Different compositions can expose apps with identical original URLs on separate servers, because each has its own routing table and catalog selection.
OpenAPI uses the public mounted paths and distinct operation IDs. MCP tools, prompts, instructions and annotated controller endpoints use those same placements. Repeated mounts receive separate toolboxes and unique tool/prompt names. Existing MCP visibility annotations still apply. See OpenAPI generation, MCP setup and controller endpoints.
Generate catalog files where source comments and the Crystal compiler are
available, as described in those guides. Regenerate mcp.yml when selections or
mounts change. Its composition identity detects stale files; a mismatch falls
back to descriptions from compiled metadata, which do not retain source comments.
Legacy files remain supported for ordinary apps without explicit composition or
mounts. Description caches are scoped to the composition and description file.
Instance selections do not configure the template's default --routes, --docs
and --mcp options. Use Server.compose for those defaults, or add generation
code that passes your chosen instance explicitly.
Test the same composition#
require "action-controller/spec_helper"
composition = AC::Composition.new([MyApp.name])
client = AC::SpecHelper.new(composition).hot_topic
response = client.get("/myapp/auth/login")
For direct controller tests, spec_instance accepts composition: too. It binds
the requested placement's base and path parameters without executing actions or
filters. Existing test calls use the configured default composition. See
testing.
Ordered HTTP handler chains#
Each controller class provides .handler, returning a fresh HTTP::Handler for
that class and its concrete descendants. This also works on an abstract app base:
require "action-controller"
# Require both apps' controller libraries here
server = HTTP::Server.new([
App::Base.handler,
OtherAC::App::Base.handler,
HTTP::StaticFileHandler.new("www", directory_listing: false),
])
server.bind_tcp("127.0.0.1", 3000)
server.listen
Handlers are tried in order. A route miss calls the next handler, which can be another app, a static file handler or any Crystal HTTP handler. A matched action keeps its response, including an intentional 404 or an authentication failure. Response status does not turn a match into a miss. An app's controller filters run only after one of its routes matches.
Overlapping paths across different handlers are allowed: the first matching app
owns that request. Use fresh handlers for each chain or server because a handler
has a mutable next link. Request controller instances continue to be constructed
with an HTTP::Server::Context.
A raw HTTP::Server chain has no combined composition for catalog generation.
Its handler order and overlapping routes cannot be represented as distinct
operations at the same public method/path in one OpenAPI document. If unified
OpenAPI and a global MCP server are required, choose nonconflicting public paths
and use one composition for the server and catalogs. You can also put ordinary
middleware before that server's routing table and a fallback handler after it
with AC::Server.before and AC::Server.after.
Performance and operational tradeoffs#
Root selection, mount expansion, validation and catalog projection happen at compilation or initialization. A unified composition flattens the public routes into one router. A mounted request uses that router and binds its public base in the context; it does not walk mount declarations or allocate a placement per request. Handler chains pay another lookup for every app that misses.
The 9.0.0 router uses an allocation-free exact static lookup and lazy trie
matching for unescaped dynamic paths. Only successful captures become strings;
escaped paths retain LuckyRouter's decoder. Dispatch remains through Proc,
which won the measured mixed-target comparisons against callable objects and
generated integer dispatch.
Local release benchmarks found no consistent warmed single-handler or mounted-app dispatch regression in the tested fixtures. Ordinary dynamic requests allocated 32–64 fewer bytes, and nested full-request dispatch improved about 1.6–2.8%. These measurements exclude startup and network I/O and do not guarantee zero overhead for every workload. Parameterized mounts, mounted URL generation, concurrent MCP requests and real network traffic need their own measurements. See the benchmark fixtures and results.
All apps in one process share dependencies and global settings. Compositions select routes; they do not isolate mutable globals, databases, sessions or MCP configuration. If apps need separate process-wide settings or independent deployment, run them in separate processes and route traffic at a proxy.