Working With Us
We're not hiring. But here's the kind of people we want building Kazisafi with us when the time comes.
We’re not hiring. There’s no open role, no job listing, no urgency. We’re bootstrapped and before market. This isn’t a call for applications.
But we’ve been thinking about the kind of people we’d want to work with eventually, and it felt worth writing down. Partly so the right person might find it. Partly to be honest about what we care about before there’s pressure to fill a seat.
This post is long by design. It’s a sneak peek at what we value and what we want more of - the architecture, the conventions, the way we think about code. If ten minutes of reading about operations, query objects, and filter chains sounds like a chore, that’s a useful signal for both of us.
What Kazisafi Is
A payroll platform for Kenyan SMEs. A Rails monolith that handles statutory compliance, M-Pesa disbursements, leave management, attendance tracking, and invoicing. The codebase is opinionated and well-organized. We intend to keep it that way.
Managers of One
The phrase comes from Basecamp. People who set their own direction, manage their own work, and don’t need someone checking in every few hours.
We’re a small team building foundational systems. No one here has a narrow lane. You pick up the ticket, understand the domain, ship the feature, write the tests, and move on. If something breaks in production, you fix it - not because someone assigned it to you, but because you noticed.
This isn’t about heroics or long hours. It’s about ownership. You have full context, you make decisions, and you say something when something is off.
The Code
Rails 8 with Hotwire, PostgreSQL, Redis, Sidekiq, deployed via Kamal to Hetzner with AWS managed services. Server-rendered HTML with Stimulus controllers. No React, no build step, no client-side state management.
The codebase has strict conventions. Rather than listing them, here’s what they look like.
Operations Over Services
We don’t have a LeaveService with a dozen methods. We have Leave::ApproveRequest - one class, one job. The model owns its state transitions. The operation orchestrates the workflow. The controller stays thin.
module Leave class ApproveRequest include Events::Emitter
Result = Data.define(:success?, :request, :balance_before, :balance_after, :errors) do def self.success(request:, balance_before:, balance_after:) new(success?: true, request:, balance_before:, balance_after:, errors: []) end
def self.failure(errors, request: nil) new(success?: false, request:, balance_before: nil, balance_after: nil, errors: Array(errors)) end end
def self.call(...) = new(...).call
def initialize(request:, approver:, comments: nil) @request = request @approver = approver @comments = comments end
def call return authorization_error unless authorized?
balance = current_balance balance_before = balance&.available
@request.transaction do @request.approve_by!(approver: @approver, comments: @comments)
track_event(balance_before, balance&.reload&.available) emit_async(:leave_request_approved, @request, actor: @approver, comments: @comments) end
Result.success( request: @request, balance_before:, balance_after: balance&.reload&.available ) rescue => e Result.failure(e.message, request: @request) end
private
def authorized? # ... end
def current_balance # ... end
def track_event(before, after) # ... end
def authorization_error # ... end endendThe result is a Data.define - immutable, pattern-matchable, with named constructors. The operation handles authorization, balance snapshots, event emission, and audit tracking. The model just manages its own state:
def approve!(user) transaction do update!(status: :approved, approved_by: user, approved_at: Time.current) deduct_from_allocation! endend
def reject!(user) update!(status: :rejected, approved_by: user, approved_at: Time.current)endThe controller wires the request to the operation and handles the result. That’s it.
class Leave::RequestsController < ApplicationController before_action :set_company before_action :set_request, only: [:show, :approve, :reject, :cancel]
def approve @request.approve!(current_user) redirect_to leave_requests_path(@company), notice: "Leave request approved" rescue ActiveRecord::RecordInvalid => e redirect_to leave_request_path(@company, @request), alert: e.message end
private
def set_request @request = @company.leave_requests.find(params[:id]) endendThree layers. The controller doesn’t know how approval works. The operation doesn’t know about HTTP. The model doesn’t know about notifications or events.
Queries
Read-heavy logic lives in query objects. No side effects, no mutations.
module Leave class BalanceQuery attr_reader :employee
def initialize(employee) @employee = employee end
def available_days(leave_kind, year = Date.current.year) balance = employee.leave_balances.find_by(leave_kind:, year:) balance&.available || 0 end
def sufficient_balance?(leave_kind, days_requested, year = Date.current.year) available_days(leave_kind, year) >= days_requested end
def self.for_employees(employee_ids, year: Date.current.year) Leave::Balance .includes(:leave_kind, :employee) .where(employee_id: employee_ids, year:) .group_by(&:employee_id) end endendDoes this employee have enough leave? What’s available across a team? The operation calls the query before approving. The controller never touches it.
Filters
Every index page that needs filtering uses a filter object. Declare attributes, chain scopes, guard against blanks.
module Leave class RequestsFilter < BaseFilter attribute :status, :string attribute :leave_kind, :string attribute :employee_id, :integer attribute :date_from, :date attribute :date_to, :date
def apply(scope) scope = filter_by_status(scope) scope = filter_by_leave_kind(scope) scope = filter_by_employee(scope) scope = filter_by_date_range(scope) scope end
private
def filter_by_status(scope) return scope if status.blank?
scope.where(status: status) end
def filter_by_date_range(scope) scope = scope.where(start_date: date_from..) if date_from.present? scope = scope.where(end_date: ..date_to) if date_to.present? scope end endendLeave::RequestsFilter.call(scope, params). Each method returns the scope untouched if there’s nothing to filter. Predictable, testable.
And the Rest
- Domain-namespaced models - payroll code lives with payroll code, leave management stays together
- ViewComponents over partials - explicit interfaces, testable, structured
- Association extensions for contextual queries instead of scattered scopes
- Event-driven side effects - operations emit events, notifiers subscribe, mailers deliver
- Open Props for all CSS values - no hardcoded pixels, no magic numbers
If this looks familiar, good. If it doesn’t but you’re curious, that’s fine too. The patterns are documented and consistent. You’d learn them.
Person-Driven, AI-Assisted
We use Claude Code. It’s woven into the workflow - not as a crutch, but as a multiplier. The codebase has rules files, architectural documentation, and strict conventions specifically so that AI tooling can follow the patterns we’ve established. It works because the patterns are clear.
The ratio is 75:25. You drive, the AI assists.
You need to understand why an operation wraps a transaction, why the model owns its state machine, why the filter returns the scope untouched when there’s nothing to filter. If you can read the code above and explain the decisions behind it - why the controller doesn’t call request.approve! directly, why the query is a separate object, why filters chain instead of nest - then you’d fit here.
If you can’t, AI tools won’t bridge that gap. They’ll just generate confident-looking code that doesn’t belong.
What We’re Looking For
You’d own features end-to-end. A payroll export isn’t just a controller action - it’s the calculator that computes PAYE across progressive tax bands, the generator that produces KRA-compliant P10 files, the background job that handles batch processing, the Turbo frame that updates the UI, and the tests that ensure a shilling never goes missing.
The domains are complex. Kenyan payroll involves NSSF two-tier contributions, SHIF deductions, housing levy, PAYE with insurance and pension relief, and the Employment Act’s one-third rule. Leave management tracks balances across years with carryover expiry. Attendance needs sub-10ms response times for biometric clock-ins.
What matters:
- Strong Rails fundamentals - ActiveRecord, callbacks, concerns, and knowing when not to use them
- Hotwire fluency - Turbo Frames, Turbo Streams, Stimulus controllers that do one thing well
- CSS without frameworks - Open Props, BEM naming, mobile-first, dark mode
- PostgreSQL comfort - efficient queries, JSONB, no N+1s
- Testing discipline - RSpec for business-critical logic, not checkbox coverage
- Curiosity about the domain - payroll compliance isn’t glamorous, but getting it wrong costs people money
We’re not looking for someone who’s done everything. We’re looking for someone who figures things out and ships clean work.
What’s Ahead
We’re genuinely excited about what’s next. The core payroll engine works. Now the problems get more interesting.
Payments. Salary disbursements, invoice settlements, contractor payouts. M-Pesa B2C, PesaLink, and eventually direct bank transfers. The kind of work where you’re thinking about idempotency keys, reconciliation loops, and what happens when a payment provider returns an ambiguous response at 2am.
Mobile. Hotwire Native wrapping the existing web app into native iOS and Android shells. Same codebase, same server-rendered HTML, native navigation and push notifications. No separate mobile team, no API-first rewrite. The one-person framework promise extended to the app store.
And there’s more we’re not ready to talk about yet. But the shape of the work is clear: real infrastructure, real complexity, real impact on how Kenyan businesses run payroll.
How We Work
Async-first. No standups. We communicate through Fizzy, with enough context that no one needs a meeting to understand what’s happening.
Deadlines over hours. We care about delivery, not time logged. Take the time you need to ship quality work.
Deliberately, not frantically. This handles people’s salaries. We move with intention. No rushing, no cutting corners on financial logic, no “we’ll fix it later” on compliance features.
Simplicity. Three lines of clear code over a premature abstraction. A plain query over a clever one. We optimize for the person reading the code six months from now.
Have a life. Non-negotiable. We don’t romanticize overwork. The best code comes from people who are rested and have interests outside of their editor.
What This Isn’t
This isn’t a job posting. There’s no application form, no interview pipeline, no timeline. We’re bootstrapped, pre-revenue, building something we believe in with the constraints that come with that.
But if you read this far and something resonated - the architecture, the conventions, the way we think about work - we’d like to know you exist. Reach out at [email protected]. Tell us what you’ve built and what you care about. Skip the cover letter and the CV.