I do enjoy software design.
I find it to be a creative activity, challenging and satisfying. In fact this post is a story about a Rails project I’ve recently worked on, the various design alternatives I considered for a specific component, the choices I made and the reasoning behind them.
It’s not about the final design, it’s about how I got there (says cheesy me).
Context and requirements
Let’s start by describing a simplified version of the application.
The public side of our simple app has to present information about a lot of recurring events, and provide search and filter capabilities. Also, let’s say that we are dealing with a sport center, and that these events I mentioned are the activities the center offers and their recurring sessions.
What is a sport activity? We can look at its fields to get an idea. At the very least, we have:
- a title (String)
- a description (String)
- a start time (Time)
- a room/location (foreign key to another model)
- some descriptor tags (other models, linked through a join table)
So far, it looks quite straightforward.
However, although the consumer side of the application is mostly static – as all it does is to display data in response to certain request parameters – the requirements the data has to satisfy turn out to be more complex.
In fact, here is a more functional description of our sport activities.
- Each individual activity has several sessions:
- which happen on different days,
- and share the same attributes (title, location, etc),
- so that it makes sense to consider a set of sessions as the same activity.
- Sessions are periodic and recurring:
- with a schedule based on custom rules, definable via CMS,
- but they also support exceptions, so that individual rule-based sessions can be removed and extra non-rule-based sessions can be added.
- Activities have a central access point to modify their attributes:
- when an activity is updated, the changes will be reflected on all its sessions;
- however, individual sessions can be customized (e.g. a change of location for a specific day),
- such customizations take precedence over the activity’s defaults,
- and can be reverted if necessary.
Not so straightforward anymore, right?
Different solutions, different problems
To be honest, there are a lot of ways to implement this kind of system. Some better than others, each with some trade offs.
Let’s look at a couple of “solutions” I briefly considered and quickly discarded.
First option: one record per activity.
I considered using a model to represent the activities, with a distinct record for each individual activity type (e.g. fencing, yoga). These records could then be associated to a set of days (with days being a separate model) using a
has_and_belongs_to_many association (and a join table). Each Activity associated to a set of days, each day linked to a list of activities. Simple enough.
However, while this would provide a central interface to modify all the sessions for a specific activity, it would offer no support to customize individual sessions. In fact, this solution does not have individual sessions to begin with, just relationships between the models.
A way to enable per-day customizations would be to clone the original activity records as new variants, customize them and associate them to specific days, but it feels messy and fragile.
What about reverting the changes? What about duplicated data getting out of sync? I did not like it.
Second option: one record per session.
Given a base activity (e.g. fencing or yoga), a number of individual sessions (copies of the base) could be created, each containing: a
date field (or a foreign key linking to an independent day record), and an identifier to group them together.
This would provide great flexibility to customize the individual sessions, but would fail at everything else. Not only would it be built on pandemic data duplication, but the only way to modify an activity would be to update all its clones/sessions. What if one of the sessions had been customized? It would require additional logic to handle that scenario.
No, definitely not something I could use.
Base Activity templates and shallow Sessions
Two things were clear: first, I needed a model to represent the abstract activities and hold their attributes; secondly, I could not use them directly as sessions. I had in fact decided that sessions were to exist as a separate model, different from the activities.
I liked this solution, and I implemented it using three models:
Activity::Basemodel, the base record acting as a template.
Activity::Sessionmodel, representing the concrete sessions assigned to specific days.
DayPlanmodel, a very simple entity.
Which are associated with these declarations:
1 2 3 4 5 6 7 8 9 10 11 12
class DayPlan < ActiveRecord::Base has_many :sessions, class_name: "Activity::Session", dependent: :destroy end class Activity::Base < ActiveRecord::Base has_many :sessions, class_name: "Activity::Session", dependent: :destroy end class Activity::Session < ActiveRecord::Base belongs_to :base, class_name: "Activity::Base" belongs_to :day_plan end
Let’s see what they do and how they interact.
Activity::Base represents individual activities. It holds their data, but it’s not tied to specific dates. It’s abstract, although not OO-speaking.
It also holds activities’ start times, which sounds odd considering that this model is not directly associated to a day. The main reason is that the start time of an activity is shared by all its sessions, and it’s one of the fields that need to be modified in bulk.
It made sense to store this piece of data on this model, but I admit that this design decision required a lot of thought, and I’m aware that it greatly affects the user experience of how activities and sessions are managed and presented.
The way I see it – the way I implemented it – an activity is a precise entity in a sport center’s weekly schedule. If the same activity needs to happen at different times, then we are dealing with two different activities. For example, a sport center might have two yoga classes, one in the morning and one in the afternoon. Each of these classes is a different activity, maybe with the same title and similar descriptions (text), but with different starting times and, possibly, more differences in the other attributes (e.g. different rooms).
Activity::Session represents the concrete sessions associated with each day (through a foreign key).
Session records are associated to
Base records, and contain a lot of the same fields of the
Base (title, location, etc), but with an important difference: they default to
As long as they are empty, all data-read operations will be delegated to the associated
Base instance. However, as soon as a
Session’s field is customized, that field will take precedence over the corresponding value from the
Base. I’ll explain how this works in a moment.
DayPlan model represents individual days and, as you can imagine, holds a date value. As several sessions will be associated to the same
DayPlan record, day plans become a container of sessions.
An important observation on the
DayPlan class: instead of using a model and an association, I could have just stored the dates as a field on the
Session model. However:
- even with indexes (SQL), searching the sessions by
day_plan_id(INT) is faster than searching by
- Dealing with day records makes a lot of stuff easier (as opposed to arbitrary date values on the sessions).
- Day records contain other data and logic.
And yet, I still think that the use of
DayPlan records might be debatable. We need 365 day plans per year, and several times that number to support multiple sport centers – with each center managing its own calendar.
Still, they are (informally) non-mutable objects, their quantity and rate of growth are known well in advance, they provide a familiar metaphor, and they make session-fetch operations significantly faster and simpler.
I considered the last one an important benefit. Retrieving the sessions for a specific timeframe (let’s say a week) is a very common operation for the app, and needs to be as fast and as simple (read: solid) as possible.
On a different note, I also considered deep-joining the models with a
has_many through: association. In theory, it might have been useful to let a
DayPlan know what kind of
Activity::Bases are part of its schedule, and vice versa: get the days an activity is scheduled for.
I decided not to because it would have increased the complexity of the models’ interfaces, more than I was willing to accept.
Another thing I though about was the relationship between
Activity::Session. They share a lot of attributes, and I did consider implementing the latter as a subclass of the former, with single table inheritance.
Eventually I didn’t, because:
- Aside from the shared fields, each model requires specific data that would be useless to the other. Using the same table would have made the rows needlessly heavier for both models.
- Even ignoring the extra columns, the two models implement different logic and expose different interfaces.
- As I said, I strived to keep read operations quick. The application will have lots of sessions but much fewer activities. I didn’t want to slow down the activities’ fetch operations by mixing them together.
The two models do share some features, though, and things are kept DRY by implementing these bits in external modules included in the two classes.
Conditional delegation, the lazy way
In one of the previous paragraphs I mentioned that sessions will delegate read operations to their associated activity, unless that attribute has been customized. Let’s see how this works.
I wanted the delegation logic to be:
- Conditional, based on the values of the object’s attributes (present vs.
- lazy and dynamic, to be able to pick up changes to the object,
- flexible and optional, as I wanted to be able to:
- let the session decide what to return
- directly read the value from the base activity, even when customized on the session (e.g. to inform CMS users about the default values)
- just read the value from the session, even if nil (e.g. to pre-populate form fields)
- let the session decide what to return
With this premise, let’s have a look at a stripped down implementation of the concept:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
class Activity::Session < ActiveRecord::Base belongs_to :base, class_name: "Activity::Base" def fetch(property) session_value = self.send(property) if session_value.blank? base.send(property) else session_value end rescue nil end end
Not too complex, and it would result in this interface:
1 2 3 4 5 6 7 8 9
# read whatever is stored on the session, even if nil @session.location # directly access the attribute on the activity, bypassing # whatever might have been customized on the session @session.base.location # let the session decide what to return @session.fetch :location
Which is exactly what I needed. Mission accomplished.