DEV Community

Marvin Ahlgrimm
Marvin Ahlgrimm

Posted on

Applying the Presenter Pattern in Marten

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)