Tuesday, September 30, 2025

Building a Flexible Landlord Data Model for Property Management

Paul (Founder)
Development

Landlords come in various forms: individuals renting single properties, married couples managing joint portfolios, family trusts holding inheritance properties, and property management companies owning dozens of units. A robust landlord data model must accommodate this diversity whilst maintaining simplicity for the common case (individual owners) and avoiding over-engineering for edge cases.

Week 40 built LetAdmin's landlord management system from the ground up: defining the data model, implementing CRUD operations, designing management interfaces, and integrating landlords throughout property workflows. This article explores the design decisions that balance flexibility with maintainability.

What Your Team Will Notice

The landlords section appears in the main navigation, providing dedicated space for landlord management separate from property operations. Click through to see all landlords organized by type: individuals, joint individuals (co-owners treated as a unit), and organizations.

Creating a new landlord presents form fields adapting to landlord type. Individual landlords capture title, first name, last name, email, phone, and address. Joint individuals add a second set of name fields for the co-owner. Organizations replace personal titles and names with company name and registration number fields. The form shows only relevant fields for the selected type, avoiding cluttered interfaces with inapplicable options.

Each landlord's detail page displays their complete profile alongside properties they own (with ownership percentages for jointly-owned properties). Contact information, addresses, and notes provide comprehensive context for agency staff managing landlord relationships. Edit buttons allow quick updates without navigating away from the detail view.

From property pages, adding or changing landlords works through intuitive dropdowns populated with agency landlords. The ownership management modal (covered in the previous article) handles complex ownership scenarios, whilst simple sole-ownership cases remain straightforward single selections.

Under the Bonnet: Landlord Entity Design

The landlord model supports three primary types with type-specific attributes:

class Landlord < ApplicationRecord
  belongs_to :agency
  acts_as_tenant :agency

  has_many :property_ownerships, dependent: :destroy
  has_many :properties, through: :property_ownerships

  # Landlord types
  enum landlord_type: {
    individual: 'individual',
    joint: 'joint',
    organisation: 'organisation'
  }

  # Individual/joint landlord attributes
  validates :title, presence: true, if: :individual_or_joint?
  validates :first_name, presence: true, if: :individual_or_joint?
  validates :last_name, presence: true, if: :individual_or_joint?

  # Joint landlord second person
  validates :title_2, presence: true, if: :joint?
  validates :first_name_2, presence: true, if: :joint?
  validates :last_name_2, presence: true, if: :joint?

  # Organization attributes
  validates :company_name, presence: true, if: :organisation?

  # Contact information (all types)
  validates :email, format: { with: URI::MailTo::EMAIL_REGEXP },
                   allow_blank: true

  def full_name
    case landlord_type
    when 'individual'
      "#{first_name} #{last_name}"
    when 'joint'
      "#{first_name} #{last_name} & #{first_name_2} #{last_name_2}"
    when 'organisation'
      company_name
    end
  end

  def display_name
    # Excludes titles for cleaner UI presentation
    full_name
  end

  def salutation
    case landlord_type
    when 'individual'
      "#{title} #{last_name}"
    when 'joint'
      # "Mr & Mrs Smith" or "Mr Smith & Ms Jones" depending on shared surname
      if last_name == last_name_2
        "#{title} & #{title_2} #{last_name}"
      else
        "#{title} #{last_name} & #{title_2} #{last_name_2}"
      end
    when 'organisation'
      company_name
    end
  end

  private

  def individual_or_joint?
    individual? || joint?
  end
end

This design uses optional fields rather than separate tables for different landlord types. While separate tables (Single Table Inheritance or polymorphic associations) might seem cleaner architecturally, they complicate queries and associations. A single table with type-specific validation strikes better balance between flexibility and simplicity.

Smart Name Handling

The salutation method demonstrates thoughtful detail handling for joint landlords:

  • Shared surname: "Mr & Mrs Smith" (common case)
  • Different surnames: "Mr Smith & Ms Jones" (modern families, business partners)
  • Title exclusion in display_name: Removes titles for cleaner UI presentation throughout the application

These small touches improve user experience significantly compared to rigid formatting that doesn't accommodate real-world variations.

CRUD Operations and Controllers

Standard resourceful routes provide complete landlord management:

# config/routes.rb
resources :landlords do
  member do
    get :properties # Landlord's property portfolio
  end
end

The controller implements typical CRUD with multi-tenant scoping:

class LandlordsController < ApplicationController
  before_action :set_landlord, only: [:show, :edit, :update, :destroy]

  def index
    @landlords = Landlord.includes(:properties)
                        .order(created_at: :desc)
                        .page(params[:page])
                        .per(50)
  end

  def show
    @properties = @landlord.properties
                          .includes(:property_ownerships)
                          .order(reference: :asc)
  end

  def new
    @landlord = Landlord.new(landlord_type: 'individual')
  end

  def create
    @landlord = Landlord.new(landlord_params)
    @landlord.agency = current_tenant

    if @landlord.save
      redirect_to @landlord, notice: 'Landlord created successfully'
    else
      render :new, status: :unprocessable_entity
    end
  end

  def update
    if @landlord.update(landlord_params)
      redirect_to @landlord, notice: 'Landlord updated successfully'
    else
      render :edit, status: :unprocessable_entity
    end
  end

  def destroy
    @landlord.destroy
    redirect_to landlords_url, notice: 'Landlord removed successfully'
  end

  private

  def set_landlord
    @landlord = Landlord.find(params[:id])
  end

  def landlord_params
    params.require(:landlord).permit(
      :landlord_type, :title, :first_name, :last_name,
      :title_2, :first_name_2, :last_name_2,
      :company_name, :company_registration_number,
      :email, :phone, :mobile,
      :address_line_1, :address_line_2, :town, :county, :postcode,
      :notes
    )
  end
end

The acts_as_tenant scoping automatically filters queries by current agency, preventing cross-tenant data access without explicit filtering in every query.

N+1 Query Prevention

The index action includes property associations to avoid N+1 queries when displaying property counts:

@landlords = Landlord.includes(:properties)

Without this eager loading, displaying "Landlord X owns 5 properties" for each landlord would trigger separate queries per landlord. The includes loads all associated properties in two queries total regardless of landlord count.

Dynamic Form Rendering

The landlord form adapts based on selected type:

<%= form_with(model: @landlord, local: true) do |form| %>
  <!-- Landlord Type Selection -->
  <div class="form-group">
    <%= form.label :landlord_type, "Landlord Type" %>
    <%= form.select :landlord_type,
                    options_for_select([
                      ['Individual', 'individual'],
                      ['Joint Individuals', 'joint'],
                      ['Organisation', 'organisation']
                    ], @landlord.landlord_type),
                    {},
                    class: 'form-select',
                    data: { action: 'change->landlord-form#updateFields' } %>
  </div>

  <!-- Individual/Joint Fields -->
  <div data-landlord-form-target="individualFields"
       style="display: <%= @landlord.individual? || @landlord.joint? ? 'block' : 'none' %>">

    <div class="form-row">
      <%= form.label :title %>
      <%= form.select :title,
                      options_for_select(['Mr', 'Mrs', 'Miss', 'Ms', 'Dr', 'Prof'],
                                        @landlord.title),
                      { include_blank: 'Select...' },
                      class: 'form-select' %>
    </div>

    <div class="form-row">
      <%= form.label :first_name %>
      <%= form.text_field :first_name, class: 'form-control' %>
    </div>

    <div class="form-row">
      <%= form.label :last_name %>
      <%= form.text_field :last_name, class: 'form-control' %>
    </div>
  </div>

  <!-- Joint Second Person Fields -->
  <div data-landlord-form-target="jointFields"
       style="display: <%= @landlord.joint? ? 'block' : 'none' %>">

    <h4>Second Individual</h4>

    <div class="form-row">
      <%= form.label :title_2, "Title" %>
      <%= form.select :title_2,
                      options_for_select(['Mr', 'Mrs', 'Miss', 'Ms', 'Dr', 'Prof'],
                                        @landlord.title_2),
                      { include_blank: 'Select...' },
                      class: 'form-select' %>
    </div>

    <div class="form-row">
      <%= form.label :first_name_2, "First Name" %>
      <%= form.text_field :first_name_2, class: 'form-control' %>
    </div>

    <div class="form-row">
      <%= form.label :last_name_2, "Last Name" %>
      <%= form.text_field :last_name_2, class: 'form-control' %>
    </div>
  </div>

  <!-- Organisation Fields -->
  <div data-landlord-form-target="organisationFields"
       style="display: <%= @landlord.organisation? ? 'block' : 'none' %>">

    <div class="form-row">
      <%= form.label :company_name %>
      <%= form.text_field :company_name, class: 'form-control' %>
    </div>

    <div class="form-row">
      <%= form.label :company_registration_number %>
      <%= form.text_field :company_registration_number,
                          class: 'form-control',
                          placeholder: 'e.g., 12345678' %>
    </div>
  </div>

  <!-- Contact Information (all types) -->
  <div class="form-section">
    <h4>Contact Information</h4>

    <div class="form-row">
      <%= form.label :email %>
      <%= form.email_field :email, class: 'form-control' %>
    </div>

    <div class="form-row">
      <%= form.label :phone %>
      <%= form.telephone_field :phone, class: 'form-control' %>
    </div>

    <div class="form-row">
      <%= form.label :mobile %>
      <%= form.telephone_field :mobile, class: 'form-control' %>
    </div>
  </div>

  <!-- Address (all types) -->
  <div class="form-section">
    <h4>Address</h4>
    <!-- Standard UK address fields -->
  </div>

  <%= form.submit class: 'btn btn-primary' %>
<% end %>

<script>
// Stimulus controller for dynamic field visibility
class LandlordFormController extends Controller {
  static targets = ['individualFields', 'jointFields', 'organisationFields']

  updateFields(event) {
    const type = event.target.value

    this.individualFieldsTarget.style.display =
      (type === 'individual' || type === 'joint') ? 'block' : 'none'

    this.jointFieldsTarget.style.display =
      (type === 'joint') ? 'block' : 'none'

    this.organisationFieldsTarget.style.display =
      (type === 'organisation') ? 'block' : 'none'
  }
}
</script>

This approach keeps all field definitions in one form whilst showing only relevant fields based on landlord type, balancing code maintainability with UX clarity.

Integration with Property Management

Properties integrate landlord associations throughout their interfaces:

<!-- Property show page landlord section -->
<div class="property-card">
  <div class="card-header">
    <h3><%= @property.jointly_owned? ? 'Landlords' : 'Landlord' %></h3>
    <%= link_to 'Manage Ownership', '#',
                data: { action: 'click->modal#open' },
                class: 'btn btn-sm' %>
  </div>

  <div class="card-body">
    <% if @property.property_ownerships.any? %>
      <ul class="landlord-list">
        <% @property.property_ownerships.includes(:landlord).each do |ownership| %>
          <li>
            <%= link_to ownership.landlord.display_name,
                        landlord_path(ownership.landlord) %>
            <% if @property.jointly_owned? %>
              <span class="ownership-badge">
                <%= ownership.ownership_percentage %>%
              </span>
            <% end %>
          </li>
        <% end %>
      </ul>
    <% else %>
      <p class="text-muted">No landlord assigned</p>
      <%= link_to 'Assign Landlord', '#',
                  data: { action: 'click->modal#open' },
                  class: 'btn btn-outline' %>
    <% end %>
  </div>
</div>

This integration ensures landlord information remains accessible throughout property workflows without requiring staff to navigate away from property contexts.

Testing Landlord Types and Validation

Comprehensive tests verify type-specific validation and behavior:

RSpec.describe Landlord, type: :model do
  describe "individual landlords" do
    it "requires title, first name, and last name" do
      landlord = build(:landlord, landlord_type: 'individual',
                      title: nil, first_name: nil, last_name: nil)

      expect(landlord).not_to be_valid
      expect(landlord.errors[:title]).to be_present
      expect(landlord.errors[:first_name]).to be_present
      expect(landlord.errors[:last_name]).to be_present
    end

    it "formats full_name correctly" do
      landlord = build(:landlord, landlord_type: 'individual',
                      first_name: 'Jane', last_name: 'Smith')

      expect(landlord.full_name).to eq('Jane Smith')
    end
  end

  describe "joint landlords" do
    it "requires both sets of names" do
      landlord = build(:landlord, landlord_type: 'joint',
                      first_name: 'Jane', last_name: 'Smith',
                      first_name_2: nil, last_name_2: nil)

      expect(landlord).not_to be_valid
      expect(landlord.errors[:first_name_2]).to be_present
      expect(landlord.errors[:last_name_2]).to be_present
    end

    it "formats joint names correctly" do
      landlord = build(:landlord, landlord_type: 'joint',
                      first_name: 'Jane', last_name: 'Smith',
                      first_name_2: 'John', last_name_2: 'Smith')

      expect(landlord.full_name).to eq('Jane Smith & John Smith')
    end

    it "handles different surnames appropriately" do
      landlord = build(:landlord, landlord_type: 'joint',
                      title: 'Ms', first_name: 'Jane', last_name: 'Smith',
                      title_2: 'Mr', first_name_2: 'John', last_name_2: 'Jones')

      expect(landlord.salutation).to eq('Ms Smith & Mr Jones')
    end
  end

  describe "organisation landlords" do
    it "requires company name" do
      landlord = build(:landlord, landlord_type: 'organisation',
                      company_name: nil)

      expect(landlord).not_to be_valid
      expect(landlord.errors[:company_name]).to be_present
    end

    it "does not require individual names" do
      landlord = build(:landlord, landlord_type: 'organisation',
                      company_name: 'Smith Properties Ltd',
                      first_name: nil, last_name: nil)

      expect(landlord).to be_valid
    end

    it "uses company name as full_name" do
      landlord = build(:landlord, landlord_type: 'organisation',
                      company_name: 'Smith Properties Ltd')

      expect(landlord.full_name).to eq('Smith Properties Ltd')
    end
  end
end

These tests ensure validation logic remains correct as landlord functionality evolves.

What's Next

The landlord data model enables rich features: landlord statements showing rental income and expenses per owner, landlord portals allowing direct access to property information, landlord document management for contracts and compliance paperwork, and sophisticated reporting analyzing portfolio performance by landlord type and ownership structure.

Future enhancements might include landlord communication templates (email templates for common scenarios like rent reviews or maintenance notifications), landlord approval workflows (requiring landlord sign-off for major property decisions), and landlord relationship scoring (identifying engaged versus hands-off landlords for tailored service approaches).

By designing a flexible landlord model that accommodates common cases simply whilst supporting complex scenarios, LetAdmin provides the foundation for comprehensive landlord relationship management that scales from small agencies managing dozens of landlords to large operations serving hundreds.