I recently ran into a problem in one of my Marten projects. My Journey
model — originally quite simple — ended up bloated with methods like travel_period
, total_costs
, sidebar data prep and more. Nearly every view needed these calculations, so my model was being polluted with logic that didn’t belong there.
Marten templates doesn't allow complex calculations, so I had to do these calculations in my models and access it in my template. But this made the model huge and hard to maintain. It felt wrong to mix UI logic with data persistence.
I wanted to keep my models clean and focused on their primary role: representing data in the database. But I also needed a way to prepare data for my templates without cluttering the model with view-specific logic.
To solve this, I borrowed a pattern from Rails and other ecosystems: the Presenter Pattern. Instead of stuffing the Journey
model, I moved view-specific logic into a JourneyPresenter
class. I then plugged that into my handlers so my templates could work cleanly with journey.
Here’s how I set it up.
Extracting a Presenter for my Journey
model
My Journey
model acts as a central wrapper — holding transports, accommodations, travelers, days, and so on. All my journey views show a sidebar with accumulated price, total traveler count, and other derived data. That made it a perfect candidate for moving presentation logic out of the model.
Initially I thought to use Object#delegate
, but manually listing every method felt brittle.
Thankfully Crystal’s macro system allowed me to generate delegations compile-time. The result: a reusable Delegate
module that forwards all public, zero-argument methods from the model and mixes in Marten::Template::Object::Auto
, making them accessible in Marten templates.
module MartenPresenters::Delegate
include Marten::Template::Object::Auto
macro present(klass)
{% ivar_name = klass.resolve.name.split("::").last.underscore.id %}
{% unless @type.methods.any?(&.name.== :initialize) %}
def initialize(@{{ivar_name}} : {{klass}}); end
{% end %}
getter {{ivar_name}} : {{klass}}
{% for m in klass.resolve.methods %}
{% if m.visibility == :public && m.args.empty? %}
{% next if @type.methods.any?(&.name.== m.name) %}
def {{m.name.id}}
@{{ivar_name}}.{{m.name.id}}
end
{% end %}
{% end %}
end
end
This modules macro present
takes a class, which is used to create a initializer that takes an instance of the class we want to present. It also creates delegation functions for all public methods that take no arguments (in assumption they are getters).
Sidenote: This could be extended to use a blacklist of methods to exclude.
The macro generates methods that delegate to the wrapped model, so I only need to call start_date!
instead of journey.start_date!
.
The present Journey
call wraps up model attributes neatly and compile-time delegation avoids manual boilerplate.
Defining JourneyPresenter
class JourneyPresenter
include MartenPresenters::Delegate
macro finished
present Journey
end
def end_time_formatted
end_date!.to_s("%Y-%m-%d")
end
def total_costs
total = Money.new(0, "EUR")
days.each do |day|
day.events.each { |e| total += e.price.not_nil! }
end
total.format(symbol_first: false)
end
def sorted_days
days.order(:date)
end
def total_costs_per_traveler
total = Money.new(0, "EUR")
days.each { |d| d.events.each { |e| total += e.price.not_nil! } }
(total / travelers.count).format(symbol_first: false)
end
def travel_period
(end_date! - start_date!).days + 1
end
end
Important note: I wrap present Journey
inside a macro finished
block. Without deferring it, the compiler wouldn’t have fully resolved Journey
’s methods yet — so delegation wouldn’t include them all.
All Journey
fields are directly accessible, e.g. #start_date!
or #end_date!
!
This presenter now handles formatting dates, accumulates prices and travelers, and provides ordered collections. Models stay lean, and templates stay expressive and focused.
Injecting the Journey Presenter into Handlers
I wanted a quick way to make journey available in my templates, so I built this mix-in:
module HasJourneyContext
macro included
before_render :set_journey_context
end
@journey : Journey?
private def set_journey_context
# Inject the presenter into the templates context
context[:journey] = JourneyPresenter.new(journey)
end
private def journey
@journey ||= Journey.get!(access_token: params["access_token"])
rescue Marten::DB::Errors::RecordNotFound
raise Marten::HTTP::Errors::NotFound.new("Journey not found")
end
end
Using the Presenter in a Real Handler
Here’s an example handler that includes the mix-in:
class TravelerListHandler < Marten::Handlers::Template
include HasJourneyContext # Include for simple usage of the presenter
template_name "travelers/list.html"
before_render :set_travelers
private def set_travelers
context[:travelers] = Traveler
.filter(journey: journey)
.order("-created_at")
end
end
By including HasJourneyContext
, this handler automatically injects a fully prepared JourneyPresenter
into the templates context.
What This Achieves
- Models stay focused on persistence — no mix of UI logic
- Templates call
{{ journey.travel_period }}
or{{ journey.total_costs }}
directly - Presenter class is testable in isolation
- Handlers are lean and declarative
- Macros generate safe, compile-time delegations
- No global state, no hidden context—everything is explicit
Everything just works, and it’s easy to extend or test.
Top comments (0)