Kulcare Docs
GeneralPatterns

Design Patterns & Conventions

This document outlines the core design patterns and conventions used throughout the Kulcare API Backend, ensuring consistency and maintainability across the codebase.

Core Patterns Overview

The application uses several established patterns:

  • Interactor Pattern - Business logic encapsulation
  • Repository Pattern - Data access abstraction
  • Service Objects - Complex operations
  • Policy Objects - Authorization logic
  • Serializer Pattern - API response formatting
  • Form Objects - Input validation
  • Query Objects - Complex database queries

Interactor Pattern (Primary Business Logic)

What is an Interactor?

Interactors encapsulate business logic into single-purpose objects that follow the Single Responsibility Principle. They are the heart of the application's business logic layer.

Basic Structure

# parts/clinic/interactors/create_appointment.rb
class CreateAppointment < BaseInteractor
  # Define expected context
  delegate :patient_id, :doctor_id, :appointment_date, :user, to: :context
  
  def call
    # Business logic steps
    validate_inputs
    check_availability
    create_appointment
    send_notifications
    update_analytics
  rescue StandardError => e
    context.fail!(error: e.message)
  end
  
  private
  
  def validate_inputs
    context.fail!(error: "Patient required") unless patient_id.present?
    context.fail!(error: "Invalid date") if appointment_date < Date.today
  end
  
  def check_availability
    unless doctor_available?
      context.fail!(error: "Doctor not available at this time")
    end
  end
  
  def create_appointment
    context.appointment = Appointment.create!(
      patient_id: patient_id,
      doctor_id: doctor_id,
      appointment_date: appointment_date,
      status: 'scheduled'
    )
  end
  
  def send_notifications
    AppointmentMailer.confirmation(context.appointment).deliver_later
    SmsService.send_appointment_confirmation(context.appointment)
  end
  
  def doctor_available?
    !Appointment.exists?(
      doctor_id: doctor_id,
      appointment_date: appointment_date,
      status: ['scheduled', 'confirmed']
    )
  end
end

Using Interactors

# In controller
class AppointmentsController < ApplicationController
  def create
    result = CreateAppointment.call(
      patient_id: params[:patient_id],
      doctor_id: params[:doctor_id],
      appointment_date: params[:appointment_date],
      user: current_user
    )
    
    if result.success?
      render json: result.appointment, serializer: AppointmentSerializer
    else
      render json: { error: result.error }, status: :unprocessable_entity
    end
  end
end

Organizers (Composing Interactors)

# parts/clinic/interactors/complete_consultation.rb
class CompleteConsultation < BaseOrganizer
  organize CreateCareplan,
           GenerateBill,
           UpdateMedicalRecords,
           SendFollowUpReminders,
           UpdateAppointmentStatus
end

# Each interactor in the chain receives the context
# If any fails, the chain stops and rolls back

BaseInteractor Implementation

# parts/core/interactors/base_interactor.rb
class BaseInteractor
  include Interactor
  
  # Common functionality for all interactors
  def self.call(context = {})
    context = Context.build(context) if context.is_a?(Hash)
    new(context).tap(&:run).context
  end
  
  around do |interactor|
    ActiveRecord::Base.transaction do
      interactor.call
    end
  end
  
  # Helper methods available to all interactors
  protected
  
  def current_user
    context.current_user || context.user
  end
  
  def current_clinic
    context.current_clinic || current_user&.active_clinic
  end
  
  def log_activity(action, resource)
    ActivityLog.create!(
      user: current_user,
      action: action,
      resource: resource,
      metadata: context.to_h
    )
  end
end

Service Objects Pattern

When to Use Services vs Interactors

  • Interactors: Business logic with context and failure states
  • Services: Utility operations, external API calls, complex calculations

Service Object Structure

# parts/clinic/services/appointment_scheduler.rb
class AppointmentScheduler
  def initialize(clinic, date_range = nil)
    @clinic = clinic
    @date_range = date_range || default_date_range
  end
  
  def available_slots
    all_slots - booked_slots
  end
  
  def next_available_slot(doctor = nil)
    slots = doctor ? slots_for_doctor(doctor) : available_slots
    slots.first
  end
  
  def book_slot(slot, patient)
    raise SlotUnavailable unless available_slots.include?(slot)
    
    Appointment.create!(
      clinic: @clinic,
      patient: patient,
      time_slot: slot
    )
  end
  
  private
  
  def all_slots
    # Generate all possible slots based on clinic schedule
  end
  
  def booked_slots
    @clinic.appointments
           .where(date: @date_range)
           .pluck(:time_slot)
  end
end

# Usage
scheduler = AppointmentScheduler.new(clinic, Date.today..Date.today + 7)
available = scheduler.available_slots

External API Services

# parts/billing/services/razorpay_service.rb
class RazorpayService
  include Singleton
  
  def initialize
    @client = Razorpay::Client.new(
      ENV['RAZORPAY_KEY_ID'],
      ENV['RAZORPAY_KEY_SECRET']
    )
  end
  
  def create_order(amount, receipt_id)
    @client.order.create(
      amount: amount * 100, # Convert to paise
      currency: 'INR',
      receipt: receipt_id
    )
  rescue Razorpay::Error => e
    Rails.logger.error "Razorpay Error: #{e.message}"
    raise PaymentGatewayError, e.message
  end
  
  def verify_signature(payment_id, order_id, signature)
    expected_signature = OpenSSL::HMAC.hexdigest(
      'SHA256',
      ENV['RAZORPAY_KEY_SECRET'],
      "#{order_id}|#{payment_id}"
    )
    
    ActiveSupport::SecurityUtils.secure_compare(signature, expected_signature)
  end
end

# Usage
RazorpayService.instance.create_order(500, "RECEIPT_123")

Repository Pattern

Data Access Abstraction

# parts/clinic/repositories/appointment_repository.rb
class AppointmentRepository
  def initialize(clinic)
    @clinic = clinic
  end
  
  def find_by_date(date)
    base_query.where(appointment_date: date)
  end
  
  def find_upcoming(limit = 10)
    base_query
      .where('appointment_date >= ?', Date.today)
      .order(:appointment_date, :appointment_time)
      .limit(limit)
  end
  
  def find_by_status(status)
    base_query.where(status: status)
  end
  
  def search(params)
    query = base_query
    query = query.where(doctor_id: params[:doctor_id]) if params[:doctor_id]
    query = query.where(patient_id: params[:patient_id]) if params[:patient_id]
    query = query.where(appointment_date: params[:date_range]) if params[:date_range]
    query
  end
  
  private
  
  def base_query
    @clinic.appointments.includes(:patient, :doctor)
  end
end

# Usage
repo = AppointmentRepository.new(current_clinic)
today_appointments = repo.find_by_date(Date.today)

Policy Objects (Authorization)

Pundit Integration

# parts/clinic/policies/appointment_policy.rb
class AppointmentPolicy < ApplicationPolicy
  # Record is the appointment being authorized
  # User is the current user context
  
  def index?
    # Anyone in the clinic can view appointments
    user_belongs_to_clinic?
  end
  
  def show?
    # Can view if: owns appointment, is the doctor, or clinic staff
    owner? || assigned_doctor? || clinic_staff?
  end
  
  def create?
    # Only clinic staff or doctors can create appointments
    clinic_staff? || clinic_doctor?
  end
  
  def update?
    # Only clinic staff or assigned doctor can update
    clinic_staff? || assigned_doctor?
  end
  
  def cancel?
    # Patient can cancel own appointment, staff can cancel any
    owner? || clinic_staff?
  end
  
  # Scopes
  class Scope < Scope
    def resolve
      if user.clinic_staff?
        scope.where(clinic: user.active_clinic)
      elsif user.doctor?
        scope.where(doctor: user.doctor_profile)
      elsif user.patient?
        scope.where(patient: user.patient_profile)
      else
        scope.none
      end
    end
  end
  
  private
  
  def owner?
    record.patient.user_id == user.id
  end
  
  def assigned_doctor?
    record.doctor.user_id == user.id
  end
  
  def clinic_staff?
    user.has_role?(:staff, record.clinic)
  end
  
  def user_belongs_to_clinic?
    user.clinics.include?(record.clinic)
  end
end

Using Policies

class AppointmentsController < ApplicationController
  def index
    @appointments = policy_scope(Appointment)
    authorize @appointments
  end
  
  def show
    @appointment = Appointment.find(params[:id])
    authorize @appointment
    render json: @appointment
  end
  
  def update
    @appointment = Appointment.find(params[:id])
    authorize @appointment
    
    if @appointment.update(appointment_params)
      render json: @appointment
    else
      render_errors(@appointment.errors)
    end
  end
end

Serializer Pattern

ActiveModel Serializers

# parts/clinic/serializers/appointment_serializer.rb
class AppointmentSerializer < ActiveModel::Serializer
  attributes :id, :appointment_date, :appointment_time, :status,
             :duration, :chief_complaint, :consultation_fee
  
  # Associations
  belongs_to :patient, serializer: PatientBasicSerializer
  belongs_to :doctor, serializer: DoctorBasicSerializer
  belongs_to :clinic, serializer: ClinicBasicSerializer
  
  # Computed attributes
  attribute :can_cancel do
    object.can_be_cancelled?
  end
  
  attribute :time_until_appointment do
    return nil if object.appointment_datetime < Time.current
    distance_of_time_in_words(Time.current, object.appointment_datetime)
  end
  
  # Conditional attributes
  attribute :private_notes, if: :staff_user?
  
  def private_notes
    object.internal_notes
  end
  
  private
  
  def staff_user?
    current_user&.clinic_staff?
  end
end

Nested Serializers

# Basic serializer for associations
class PatientBasicSerializer < ActiveModel::Serializer
  attributes :id, :name, :age, :gender
  
  def name
    "#{object.first_name} #{object.last_name}"
  end
end

# Detailed serializer for full resource
class PatientDetailSerializer < PatientBasicSerializer
  attributes :phone_number, :email, :blood_group,
             :allergies, :medical_history
  
  has_many :recent_appointments
  has_one :current_careplan
end

Form Objects

Input Validation

# parts/auth/forms/user_signup_form.rb
class UserSignupForm
  include ActiveModel::Model
  
  attr_accessor :phone_number, :country_code, :password,
                :first_name, :last_name, :email, :signed_up_as
  
  # Validations
  validates :phone_number, presence: true, format: { with: /\A\d{10}\z/ }
  validates :country_code, presence: true, inclusion: { in: %w[91 1 44] }
  validates :password, length: { minimum: 8 }, format: {
    with: /\A(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+\z/,
    message: "must include uppercase, lowercase, and number"
  }
  validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }, allow_blank: true
  validates :signed_up_as, inclusion: { in: %w[patient doctor clinic] }
  
  # Custom validations
  validate :phone_number_uniqueness
  
  def save
    return false unless valid?
    
    User.transaction do
      user = User.create!(user_attributes)
      user.create_profile(signed_up_as)
      user.send_verification_code
      user
    end
  end
  
  private
  
  def phone_number_uniqueness
    if User.exists?(phone_number: phone_number, country_code: country_code)
      errors.add(:phone_number, "already registered")
    end
  end
  
  def user_attributes
    {
      phone_number: phone_number,
      country_code: country_code,
      password: password,
      first_name: first_name,
      last_name: last_name,
      email: email,
      sign_up_intent: signed_up_as
    }
  end
end

Query Objects

Complex Database Queries

# parts/clinic/queries/available_doctors_query.rb
class AvailableDoctorsQuery
  def initialize(clinic, datetime, speciality = nil)
    @clinic = clinic
    @datetime = datetime
    @speciality = speciality
  end
  
  def call
    doctors = base_query
    doctors = filter_by_speciality(doctors)
    doctors = filter_by_availability(doctors)
    doctors = exclude_busy_doctors(doctors)
    doctors.distinct
  end
  
  private
  
  def base_query
    @clinic.doctors.active.verified
  end
  
  def filter_by_speciality(doctors)
    return doctors unless @speciality
    doctors.where(speciality: @speciality)
  end
  
  def filter_by_availability(doctors)
    day_of_week = @datetime.wday
    time = @datetime.strftime('%H:%M')
    
    doctors.joins(:schedules)
           .where(doctor_schedules: { day_of_week: day_of_week })
           .where('doctor_schedules.start_time <= ?', time)
           .where('doctor_schedules.end_time >= ?', time)
  end
  
  def exclude_busy_doctors(doctors)
    busy_doctor_ids = Appointment
      .where(appointment_datetime: @datetime)
      .where(status: ['scheduled', 'confirmed'])
      .pluck(:doctor_id)
    
    doctors.where.not(id: busy_doctor_ids)
  end
end

# Usage
query = AvailableDoctorsQuery.new(clinic, DateTime.now, 'Cardiologist')
available_doctors = query.call

Decorator Pattern

View-Specific Logic

# parts/clinic/decorators/appointment_decorator.rb
class AppointmentDecorator < SimpleDelegator
  def display_time
    appointment_time.strftime('%I:%M %p')
  end
  
  def display_date
    appointment_date.strftime('%B %d, %Y')
  end
  
  def status_badge_class
    case status
    when 'scheduled' then 'badge-primary'
    when 'confirmed' then 'badge-success'
    when 'completed' then 'badge-info'
    when 'cancelled' then 'badge-danger'
    else 'badge-secondary'
    end
  end
  
  def patient_name
    patient.full_name.presence || 'Unknown Patient'
  end
  
  def formatted_fee
    return 'Free' if consultation_fee.zero?
    "₹#{consultation_fee.to_i}"
  end
end

# Usage
appointment = AppointmentDecorator.new(Appointment.find(1))
appointment.display_time # "02:30 PM"

Observer Pattern

ActiveRecord Callbacks

# parts/clinic/models/appointment.rb
class Appointment < ApplicationRecord
  # Callbacks for state management
  before_create :set_default_status
  after_create :send_confirmation
  after_update :handle_status_change
  after_commit :update_analytics, on: [:create, :update]
  
  private
  
  def set_default_status
    self.status ||= 'scheduled'
  end
  
  def send_confirmation
    AppointmentConfirmationJob.perform_later(self)
  end
  
  def handle_status_change
    if saved_change_to_status?
      case status
      when 'confirmed'
        send_reminder_24_hours_before
      when 'cancelled'
        notify_cancellation
        release_slot
      when 'completed'
        create_medical_record
        schedule_followup
      end
    end
  end
  
  def update_analytics
    Analytics::AppointmentTracker.track(self)
  end
end

Factory Pattern

Object Creation

# parts/clinic/factories/user_factory.rb
class UserFactory
  def self.create(attributes)
    case attributes[:signed_up_as]
    when 'doctor'
      create_doctor_user(attributes)
    when 'patient'
      create_patient_user(attributes)
    when 'clinic'
      create_clinic_user(attributes)
    else
      raise ArgumentError, "Unknown user type: #{attributes[:signed_up_as]}"
    end
  end
  
  private
  
  def self.create_doctor_user(attributes)
    User.transaction do
      user = User.create!(attributes.except(:signed_up_as))
      doctor = Doctor.create!(user: user, **extract_doctor_attributes(attributes))
      clinic = create_personal_clinic(doctor)
      
      UserAccount.create!(
        user: user,
        account_id: Account::DOCTOR_ID,
        role_id: Role::OWNER_ID,
        profile_id: doctor.id
      )
      
      user
    end
  end
  
  def self.create_personal_clinic(doctor)
    Clinic.create!(
      name: "Dr. #{doctor.full_name} Clinic",
      owner: doctor.user
    )
  end
end

Value Objects

Immutable Domain Concepts

# parts/billing/values/money.rb
class Money
  attr_reader :amount, :currency
  
  def initialize(amount, currency = 'INR')
    @amount = BigDecimal(amount.to_s)
    @currency = currency
    freeze
  end
  
  def +(other)
    raise ArgumentError unless other.currency == currency
    Money.new(amount + other.amount, currency)
  end
  
  def -(other)
    raise ArgumentError unless other.currency == currency
    Money.new(amount - other.amount, currency)
  end
  
  def *(multiplier)
    Money.new(amount * multiplier, currency)
  end
  
  def to_s
    "#{currency_symbol}#{formatted_amount}"
  end
  
  def ==(other)
    amount == other.amount && currency == other.currency
  end
  
  private
  
  def currency_symbol
    case currency
    when 'INR' then '₹'
    when 'USD' then '$'
    else currency
    end
  end
  
  def formatted_amount
    amount.to_s('F')
  end
end

# Usage
fee = Money.new(500, 'INR')
tax = Money.new(50, 'INR')
total = fee + tax # Money<550 INR>

Command Pattern

Encapsulating Actions

# parts/clinic/commands/base_command.rb
class BaseCommand
  def execute
    raise NotImplementedError
  end
  
  def undo
    raise NotImplementedError
  end
end

# parts/clinic/commands/book_appointment_command.rb
class BookAppointmentCommand < BaseCommand
  def initialize(appointment_params)
    @params = appointment_params
    @appointment = nil
  end
  
  def execute
    @appointment = Appointment.create!(@params)
    send_notifications
    update_calendar
    @appointment
  end
  
  def undo
    return unless @appointment
    @appointment.destroy
    cancel_notifications
    clear_calendar
  end
  
  private
  
  def send_notifications
    # Send SMS/Email
  end
  
  def update_calendar
    # Update doctor's calendar
  end
end

# Usage with command queue
class CommandQueue
  def initialize
    @commands = []
    @current = -1
  end
  
  def execute(command)
    # Remove any commands after current position
    @commands = @commands[0..@current]
    
    # Execute and add to queue
    result = command.execute
    @commands << command
    @current += 1
    result
  end
  
  def undo
    return if @current < 0
    @commands[@current].undo
    @current -= 1
  end
  
  def redo
    return if @current >= @commands.length - 1
    @current += 1
    @commands[@current].execute
  end
end

Null Object Pattern

Avoiding Nil Checks

# parts/core/models/null_patient.rb
class NullPatient
  def id
    nil
  end
  
  def name
    "Guest Patient"
  end
  
  def phone_number
    "N/A"
  end
  
  def medical_history
    []
  end
  
  def appointments
    Appointment.none
  end
  
  def persisted?
    false
  end
end

# Usage
class Appointment < ApplicationRecord
  def patient
    super || NullPatient.new
  end
end

# Now safe to call without nil checks
appointment.patient.name # Always returns a string

Best Practices

1. Single Responsibility

Each class should have one reason to change

2. Dependency Injection

Pass dependencies rather than hard-coding them

3. Interface Segregation

Depend on abstractions, not concretions

4. Open/Closed Principle

Open for extension, closed for modification

5. Don't Repeat Yourself (DRY)

Extract common functionality into shared modules

Next Steps

On this page