By 12 June 2025, we'd established the Property model—the central entity around which every feature in a lettings management system revolves. This wasn't a hasty scaffold; it was a carefully considered data structure informed by how letting agencies actually work: the information they capture during valuations, what landlords need to know, what tenants search for, and what property portals require for syndication.
Getting the data model right early matters because changing it later is expensive. Add a field in week one, and it's trivial. Realise in month six that you need to split a single "address" field into structured components, and you're writing data migrations, updating forms, and risking data quality issues. This article explores the decisions we made and why they matter for letting agencies.
What Your Team Will Notice
When a negotiator creates a new property listing, the form feels intuitive. It captures everything needed without overwhelming them with irrelevant fields. There are sections for:
- Core details: Reference number, availability date, rental price
- Property characteristics: Bedrooms, bathrooms, reception rooms, property type (flat, house, bungalow)
- Address: Structured fields (house number, street, district, town, county, postcode) rather than a single text blob
- Marketing content: Headline, full description, up to 20 bullet points for features
- Status tracking: Available to let, under negotiation, let STC (subject to contract), withdrawn
The system enforces sensible constraints. The status field only accepts predefined values—preventing typos like "Avalable" or "Let - STC" that break filtering and reporting. The reference number is unique, preventing duplicate listings. Required fields are clearly marked, reducing data entry errors that cause listings to fail when syndicating to portals.
Crucially, the model supports associations that will grow over time. Each property can have multiple photos (via the PropertyPhoto model we discussed in the previous article), and the architecture anticipates future associations: tenancies, compliance certificates, viewing appointments, maintenance requests, and landlord details.
Under the Bonnet: The Property Model
The Property model (fully implemented on 12 June) defines the schema and business rules:
# app/models/property.rb
class Property < ApplicationRecord
has_many :property_photos, -> { order(:position) }, dependent: :destroy
accepts_nested_attributes_for :property_photos, allow_destroy: true
ALLOWED_STATUSES = [
"Available to Let",
"Under Negotiation",
"Let STC",
"Withdrawn"
].freeze
PROPERTY_TYPES = [
"Garage / Parking",
"Flat - Ground Floor",
"Flat - Upper floors",
"House",
"Bungalow"
].freeze
FURNISHED_TYPES = [
"Unknown",
"Unfurnished",
"Furnished",
"Part Furnished",
"Landlord Flexible"
].freeze
validates :status, inclusion: { in: ALLOWED_STATUSES },
allow_blank: false
end
The constants (ALLOWED_STATUSES, PROPERTY_TYPES, FURNISHED_TYPES) serve multiple purposes:
- Database integrity: The validation ensures only permitted values are stored
- Form generation: Rails can populate dropdown menus automatically from these arrays
- Future-proofing: If we add new statuses later (e.g., "Under Offer" for sales properties), we update the constant in one place
The has_many :property_photos association uses a lambda to ensure photos always load in order (by the position field). The dependent: :destroy option means deleting a property automatically deletes its photos—preventing orphaned records and conserving S3 storage.
The accepts_nested_attributes_for declaration enables creating or updating photos directly through the Property form. This Rails feature generates setter methods that handle the complexity of creating, updating, and deleting associated records in a single database transaction.
The Properties Controller: Strong Parameters
The PropertiesController (introduced during Week 24,) demonstrates Rails' strong parameters pattern:
# app/controllers/properties_controller.rb
class PropertiesController < ApplicationController
before_action :authenticate_user!
def create
@property = Property.new(property_params)
if @property.save
redirect_to @property, notice: "Property was successfully created."
else
render :new, status: :unprocessable_entity
end
end
private
def property_params
params.expect(property: [
:reference, :available_from, :price, :beds, :receptions,
:bathrooms, :status, :furnished, :headline, :description,
:house_number, :street, :district, :town, :county, :postcode,
:area, :property_type, :slug, :viewing_link,
:bullet1, :bullet2, :bullet3, :bullet4, :bullet5,
:bullet6, :bullet7, :bullet8, :bullet9, :bullet10,
:bullet11, :bullet12, :bullet13, :bullet14, :bullet15,
:bullet16, :bullet17, :bullet18, :bullet19, :bullet20,
:lat, :long
])
end
end
This property_params method is a security mechanism. Without it, malicious users could submit form data containing fields like is_admin: true or created_at: "2020-01-01", potentially corrupting data or escalating privileges. By explicitly listing permitted parameters, Rails rejects anything not on the whitelist.
The before-action callback authenticate_user! (provided by Devise) ensures only logged-in staff can create or modify properties. Unauthenticated requests redirect to the login page automatically.
Address Structuring: Why It Matters
Notice the address fields are structured (:house_number, :street, :district, :town, :county, :postcode) rather than a single text blob. This design choice has practical implications:
For search functionality: Users can search specifically for properties in "Manchester" without matching "Manchester Street" in other cities. They can filter by postcode districts (e.g., "M1") without writing complex string-matching logic.
For mapping and geocoding:
The structured address feeds into geocoding services (via the :lat and :long fields) that position properties on interactive maps. A single text field would require parsing, which is error-prone and varies by locale.
For compliance reporting: Local authority regulations sometimes differ by district or county. Structured addresses make it trivial to filter properties by jurisdiction when generating compliance reports.
For data quality:
When address components are separate, validation becomes specific. Postcodes must match UK formatting (e.g., via regex /^[A-Z]{1,2}\d[A-Z\d]? ?\d[A-Z]{2}$/). Towns can be validated against a known list if needed. This is harder with free-text address fields.
Feature Bullets: Balancing Flexibility and Structure
The model includes 20 bullet point fields (:bullet1 through :bullet20). This might seem inelegant—why not a separate PropertyFeature model with a many-to-many relationship?
The pragmatic answer is speed and simplicity. For 95% of properties, 20 bullets suffice. Creating a separate table adds:
- Additional database queries (N+1 query potential)
- Join complexity for filtering (e.g., "find properties with 'parking' AND 'garden'")
- Form complexity (dynamic adding/removing of nested records)
If we discover agencies routinely need more than 20 features, refactoring to a separate model is straightforward. But starting simple meant we could ship faster. The YAGNI principle (You Aren't Gonna Need It) applies here.
The bullet fields also support a common workflow: negotiators copy-pasting feature lists from landlord emails or previous listings. With separate records, that requires parsing and creating multiple database rows. With simple text fields, it's a quick copy-paste-save operation.
Pricing and Numeric Fields
The :price field stores the monthly rental amount. In the database schema, this is typically a decimal type with precision and scale:
# Example migration (not from Week 24 but illustrative)
create_table :properties do |t|
t.decimal :price, precision: 10, scale: 2, null: false
# Stores values up to 99,999,999.99
end
Using decimal rather than float is critical for financial data. Floating-point arithmetic has rounding errors that accumulate over calculations. Decimal types store exact values, ensuring rental amounts, deposits, and accounting totals remain precise.
The :beds, :bathrooms, and :receptions fields are integers. Some systems store these as strings (allowing "2-3 bedrooms" or "3+"), but we chose integers for simplicity and filtering. Ranges can be handled at the display layer if needed.
Validations: Data Quality Enforcement
The initial validation (:status inclusion) is minimal, but it establishes a pattern. Over subsequent weeks, we'd add:
validates :reference, presence: true, uniqueness: true
validates :price, numericality: { greater_than: 0 }
validates :beds, numericality: { only_integer: true,
greater_than_or_equal_to: 0 }
validates :postcode, format: { with: /\A[A-Z]{1,2}\d[A-Z\d]? ?\d[A-Z]{2}\z/i }
These validations run every time a property is saved, preventing invalid data from reaching the database. When a validation fails, Rails populates an errors object that the form can display to users:
<%= form_with model: @property do |f| %>
<% if @property.errors.any? %>
<div class="alert alert-danger">
<h4><%= pluralize(@property.errors.count, "error") %> prevented saving:</h4>
<ul>
<% @property.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<%= f.text_field :reference, class: "form-control" %>
<%= f.number_field :price, class: "form-control" %>
<%= f.submit "Save Property", class: "btn btn-primary" %>
<% end %>
This creates a tight feedback loop: submit invalid data, see specific error messages, correct them, resubmit. Users don't have to guess what went wrong.
Geocoding: Latitude and Longitude
The :lat and :long fields (latitude and longitude) support mapping features. While not implemented in Week 24, the fields are present, anticipating integration with services like Google Maps or Mapbox.
A typical workflow (implemented in later weeks):
- User enters a postcode
- JavaScript calls a geocoding API (e.g., Google Geocoding or UK Postcode.io)
- API returns latitude/longitude coordinates
- Form populates the hidden fields automatically
- Property saves with precise location data
This enables features like:
- "Show all properties within 5 miles of this postcode"
- Interactive maps clustering nearby properties
- Tenant searches filtered by commute distance to a workplace
What This Enables Later
The data model we established in Week 24 became the foundation for features built in subsequent weeks:
- Multi-tenancy (Week 35): Each property belongs to an agency via
belongs_to :agency - Search and filtering (Week 35): Ransack gem queries against structured fields
- API syndication (Week 35): Property attributes map cleanly to portal XML feeds
- Compliance tracking (Week 37+): Properties have many certificates (gas, electrical, EPC)
- Tenancy management: Properties have many tenancies over time
- Viewing scheduling: Properties have many viewing appointments
None of these features required restructuring the core Property model. That's the mark of a well-considered foundation.
Testing the Property Model
The RSpec tests introduced this week covered basic scenarios:
# spec/models/property_spec.rb
RSpec.describe Property, type: :model do
describe "associations" do
it { should have_many(:property_photos).dependent(:destroy) }
end
describe "validations" do
it { should validate_inclusion_of(:status).in_array(Property::ALLOWED_STATUSES) }
end
describe "nested attributes" do
it "accepts nested attributes for property_photos" do
property = Property.create!(
reference: "TEST001",
status: "Available to Let",
property_photos_attributes: [
{ image: fixture_file_upload('photo.jpg') }
]
)
expect(property.property_photos.count).to eq(1)
end
end
end
These tests might seem trivial, but they provide regression protection. If someone accidentally removes the dependent: :destroy clause from the association, tests fail immediately, preventing bugs from reaching production.
Data Migration Strategy
One challenge with structured data models is migrating existing data. If an agency switches from another system, their property records might have different structures:
- Addresses in free-text fields requiring parsing
- Bedroom counts stored as ranges ("2-3 beds") requiring normalisation
- Status values using different terminology requiring mapping
Week 24 didn't include migration scripts (there was no existing data yet), but the clean schema made future imports easier. Standardised fields mean import scripts can validate data before inserting, catching issues early.
What's Next
With the Property model established, we could build upward: search interfaces (Week 35), API endpoints for portal syndication (Week 35), and compliance tracking (Week 37+). Each feature relied on this solid data foundation.
The property model also illustrated a philosophy we'd carry forward: start simple, validate assumptions with real usage, then refactor when genuinely needed. Twenty bullet point fields might not win architecture awards, but they let agencies onboard properties quickly without fighting the software.
By the end of Week 24 (15 June), we had a deployable application that could manage properties with photos, enforce data quality, and provide a professional interface. That's a remarkable amount of progress for six days of work—made possible by thoughtful foundational decisions.
Related articles: