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
endUsing 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
endOrganizers (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 backBaseInteractor 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
endService 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_slotsExternal 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
endUsing 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
endSerializer 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
endNested 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
endForm 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
endQuery 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.callDecorator 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
endFactory 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
endValue 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
endNull 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 stringBest 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
- Review Testing Guide for testing patterns
- Check Coding Standards for style guide
- See API Reference for implementation examples