Real Estate Tech Platform Architecture

·16 min read·Specialized Domainsintermediate

Why scalable, domain-driven design matters as PropTech moves beyond simple listings

Minimalist overview diagram of a real estate tech platform with services for listings, transactions, search, and analytics flowing through an event bus

When I first dipped my toes into PropTech, I assumed the domain was mostly listings, photos, and calendars. Then I met lease start dates tied to weather events, HOA fee rules that change quarterly, and escrow logic that varies by county. Suddenly, “just a CRUD app” became a distributed system with a long memory. The trick isn’t flashy features; it’s an architecture that can absorb regulatory change and data spiky demand without grinding teams to a halt. In this post, I’ll walk through a pragmatic, real-world approach to building a Real Estate Tech Platform, focusing on how to design for change, scale, and reliable data flow without leaning on enterprise jargon.

You can expect an honest, developer-first look at the architectural patterns that matter in production: multi-tenant isolation strategies, evented workflows for listings and transactions, integrating external data providers without coupling your domain model to theirs, and a way to evolve schemas without breaking downstream consumers. We’ll cover strengths and tradeoffs, share concrete code examples from projects, and highlight where these choices shine and where they don’t.

Where real estate tech fits today

Real estate software generally splits into a few major segments: listing and marketing platforms, transaction management, property management, and analytics. At the center sits a core “property graph” that ties together assets, parties, and time-bound events (leases, offers, closings). For most teams, the challenge is that this graph changes shape as regulations, markets, and partner requirements evolve.

Architecturally, teams usually start with a monolith and end up with services for scale, compliance, and team autonomy. A typical stack looks like a relational database (PostgreSQL) for transactional integrity, a search layer (Elasticsearch or OpenSearch) for listing queries, an event bus (Kafka or Postgres logical replication) to decouple services, and a caching layer (Redis) for hot data. Frontends are React/Next.js for web and React Native for mobile. Many organizations also adopt CQRS/Event Sourcing patterns selectively rather than wholesale, which helps with auditability and evolving read models without rewriting the write path.

Compared to alternatives like pure document databases or low-code platforms, a relational core with event-driven services gives you reliable reporting, auditable compliance, and flexible read models. Low-code is fine for prototypes; document stores shine for flexible schemas, but when contracts, payments, and regulatory events demand strict lineage, a hybrid approach is more sustainable.

Core architectural concepts

Multi-tenancy and data isolation

In PropTech, tenants can be brokerages, property managers, or even marketplaces. A common early mistake is mixing tenants in wide tables with a tenant_id column and calling it secure. It works until someone writes a report query that forgets the filter. A more robust pattern is schema-per-tenant for strict isolation, or at least row-level security (RLS) with strict policies.

Below is a small but real example of enforcing tenant isolation in PostgreSQL using RLS. It’s not the only way, but it’s practical and audit-friendly.

-- Enable RLS on tables
ALTER TABLE listings ENABLE ROW LEVEL SECURITY;

-- Policy: users can only see listings for their tenant
CREATE POLICY tenant_isolation ON listings
  FOR ALL
  USING (tenant_id = current_setting('app.current_tenant_id')::uuid);

-- Helper function to set tenant context in a session
CREATE OR REPLACE FUNCTION app.set_tenant(tenant_uuid uuid)
RETURNS void AS $$
BEGIN
  PERFORM set_config('app.current_tenant_id', tenant_uuid::text, true);
END;
$$ LANGUAGE plpgsql;

In application code, you set the tenant context at the start of a request, typically using a middleware or connection pooler hook. This approach reduces the chance of cross-tenant leakage and simplifies audits. For ultra-strict compliance (e.g., government contracts), schema-per-tenant can be safer, though it complicates migrations and global analytics.

Domain-driven design for listings and transactions

Real estate domains are time-centric. A listing has status transitions, pricing events, and showing schedules. A transaction has contingencies, deadlines, and payments. Attempting to flatten these into a single “listing” table leads to brittle logic.

Event modeling helps. For example:

  • ListingCreated
  • PriceChanged
  • ShowingScheduled
  • OfferSubmitted
  • ContractSigned
  • ContingencyPassed/Failed
  • ClosingRecorded

We’ll revisit this in the evented workflows section.

Evented workflows and eventual consistency

The heart of a scalable platform is decoupling services via events. A new listing can trigger indexing, notifications, and analytics, all asynchronously. This avoids tight coupling between the listing service and downstream consumers.

Kafka is a solid choice for event streaming, but many teams start with Postgres logical replication via Debezium for simplicity. Regardless of the broker, the key is to make events immutable and versioned. Here’s a minimal example using a message envelope pattern you might use with Kafka or Postgres-based events.

{
  "event_id": "evt_01H8W5X6Y7Z8Q9R0S1T2U3V4W",
  "event_type": "ListingPriceChanged",
  "aggregate_id": "prop_01H8W5X6Y7Z8Q9R0S1T2U3V4W",
  "tenant_id": "tenant_01H8W5X6Y7Z8Q9R0S1T2U3V4W",
  "timestamp": "2025-10-14T10:30:00Z",
  "schema_version": "1.0.0",
  "payload": {
    "listing_id": "lst_01H8W5X6Y7Z8Q9R0S1T2U3V4W",
    "old_price": 450000,
    "new_price": 455000,
    "currency": "USD",
    "reason": "market_adjustment"
  }
}

For downstream services, you might expose a read model that reflects the latest listing state while preserving the audit trail. This is the essence of CQRS: the write model emits events, and the read model subscribes and updates.

Search and discovery

Listing search is a core UX and a performance hotspot. Users filter by location, price, beds, and increasingly by “lifestyle” signals (walkability, school zones). Elasticsearch/OpenSearch is typical for this. The trick is to keep the search index aligned with the business domain without coupling your schema directly to external APIs.

A practical pattern is to maintain a “canonical” listing record in the relational DB and publish a derived document to Elasticsearch. When events like ListingPriceChanged occur, the search service updates the index asynchronously. This keeps the write path fast and allows the search index to evolve independently.

File storage and media

Photos, floor plans, and contracts need durable, CDN-friendly storage. Use object storage (e.g., S3-compatible) with versioned buckets for assets. Store metadata in the database and references to the object keys. For compliance, keep original contract PDFs immutable with checksums. For tours and virtual staging, consider specialized pipelines, but start with a simple media service that handles thumbnails, previews, and access control by tenant.

Payments and compliance

Payments are not trivial. If you’re handling escrow, earnest money, or rent collection, you must integrate with a provider that supports compliance (KYC, AML) and audit trails. In most jurisdictions, payments must be traceable with timestamps, parties, and amounts. A common pattern is to capture payment events as domain events and store external transaction IDs for reconciliation.

When integrating providers, avoid tight coupling. Use an anti-corruption layer (ACL) to translate provider-specific payloads into your domain events. This shields your domain from external API churn.

Practical patterns with code

Project structure and mental model

A typical backend service structure might look like this:

src/
  api/
    handlers/
      listing.go
      transaction.go
    middleware/
      tenant.go
      auth.go
  domain/
    listing/
      events.go
      aggregate.go
    transaction/
      events.go
      aggregate.go
  application/
    commands/
    queries/
  infrastructure/
    eventbus/
      kafka.go
      outbox.go
    persistence/
      postgres.go
      migrations/
    search/
      elasticsearch.go
  shared/
    errors.go
    clock.go
cmd/
  server/
    main.go
  consumer/
    main.go

The mental model is: domain defines the business rules, application orchestrates use cases, infrastructure handles technical concerns, and API defines the edge. This separation pays off when adding new event consumers or swapping storage.

Real-world code: Listing price change with event and outbox

Here’s a realistic example of changing a listing price using an outbox pattern to ensure the event is stored before being sent to Kafka. The outbox prevents lost events during crashes.

// domain/listing/events.go
package listing

import (
    "time"
)

type Event struct {
    ID           string
    Type         string
    AggregateID  string
    TenantID     string
    Timestamp    time.Time
    SchemaVer    string
    Payload      []byte
}

type PriceChanged struct {
    ListingID string  `json:"listing_id"`
    OldPrice  float64 `json:"old_price"`
    NewPrice  float64 `json:"new_price"`
    Currency  string  `json:"currency"`
    Reason    string  `json:"reason"`
}
// application/commands/change_price.go
package commands

import (
    "context"
    "encoding/json"
    "fmt"
    "time"

    "yourapp/domain/listing"
    "yourapp/infrastructure/persistence"
    "yourapp/infrastructure/eventbus"
)

type ChangePriceCmd struct {
    ListingID string
    NewPrice  float64
    Reason    string
    TenantID  string
}

type ChangePriceHandler struct {
    repo     *persistence.ListingRepository
    outbox   *eventbus.Outbox
}

func (h *ChangePriceHandler) Handle(ctx context.Context, cmd ChangePriceCmd) error {
    // Load the current listing
    l, err := h.repo.Get(ctx, cmd.ListingID, cmd.TenantID)
    if err != nil {
        return fmt.Errorf("listing not found: %w", err)
    }

    // Business rule: price can't be reduced by more than 10% in a week without approval
    // (Trivial example; real rule would consult a policy service)
    if cmd.NewPrice < l.Price*0.9 {
        return fmt.Errorf("price reduction exceeds 10%% threshold")
    }

    oldPrice := l.Price
    l.Price = cmd.NewPrice
    l.UpdatedAt = time.Now().UTC()

    // Build event
    evt := listing.PriceChanged{
        ListingID: cmd.ListingID,
        OldPrice:  oldPrice,
        NewPrice:  cmd.NewPrice,
        Currency:  "USD",
        Reason:    cmd.Reason,
    }
    payload, _ := json.Marshal(evt)

    event := listing.Event{
        ID:          fmt.Sprintf("evt_%d", time.Now().UnixNano()),
        Type:        "ListingPriceChanged",
        AggregateID: cmd.ListingID,
        TenantID:    cmd.TenantID,
        Timestamp:   time.Now().UTC(),
        SchemaVer:   "1.0.0",
        Payload:     payload,
    }

    // Transactional outbox: save listing + outbox in the same TX
    tx, err := h.repo.BeginTx(ctx)
    if err != nil {
        return err
    }
    defer tx.Rollback(ctx)

    if err := h.repo.UpdateWithTx(ctx, tx, l); err != nil {
        return err
    }
    if err := h.outbox.StoreWithTx(ctx, tx, event); err != nil {
        return err
    }
    if err := tx.Commit(ctx); err != nil {
        return err
    }

    // The outbox consumer will pick this up and publish to Kafka
    return nil
}
// infrastructure/eventbus/outbox.go
package eventbus

import (
    "context"
    "database/sql"
    "encoding/json"
    "fmt"
    "time"

    "yourapp/domain/listing"
)

type Outbox struct {
    db *sql.DB
}

// StoreWithTx persists the event in an outbox table within an existing transaction.
func (o *Outbox) StoreWithTx(ctx context.Context, tx *sql.Tx, evt listing.Event) error {
    q := `
    INSERT INTO outbox_events (id, type, aggregate_id, tenant_id, timestamp, schema_version, payload, created_at)
    VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
    `
    payload := string(evt.Payload)
    _, err := tx.ExecContext(ctx, q, evt.ID, evt.Type, evt.AggregateID, evt.TenantID, evt.Timestamp, evt.SchemaVer, payload)
    if err != nil {
        return fmt.Errorf("outbox store failed: %w", err)
    }
    return nil
}
-- infrastructure/persistence/migrations/001_outbox.sql
CREATE TABLE outbox_events (
    id TEXT PRIMARY KEY,
    type TEXT NOT NULL,
    aggregate_id TEXT NOT NULL,
    tenant_id UUID NOT NULL,
    timestamp TIMESTAMPTZ NOT NULL,
    schema_version TEXT NOT NULL,
    payload JSONB NOT NULL,
    created_at TIMESTAMPTZ NOT NULL,
    published_at TIMESTAMPTZ
);

CREATE INDEX idx_outbox_unpublished ON outbox_events(published_at) WHERE published_at IS NULL;
// cmd/consumer/main.go
package main

import (
    "context"
    "database/sql"
    "encoding/json"
    "log"
    "time"

    "github.com/segmentio/kafka-go"
    "yourapp/domain/listing"
    "yourapp/infrastructure/eventbus"
    "yourapp/infrastructure/persistence"
)

func main() {
    db := persistence.MustConnect()
    outbox := eventbus.NewOutbox(db)
    writer := kafka.NewWriter(kafka.WriterConfig{
        Brokers:  []string{"localhost:9092"},
        Topic:    "realty-events",
        Balancer: &kafka.LeastBytes{},
    })

    for {
        events, err := outbox.FetchUnpublished(context.Background(), 100)
        if err != nil {
            log.Printf("fetch error: %v", err)
            time.Sleep(5 * time.Second)
            continue
        }
        for _, evt := range events {
            // Convert to Kafka message
            msg := kafka.Message{
                Key:   []byte(evt.AggregateID),
                Value: []byte(evt.Payload),
            }
            if err := writer.WriteMessages(context.Background(), msg); err != nil {
                log.Printf("kafka write error: %v", err)
                break
            }
            if err := outbox.MarkPublished(context.Background(), evt.ID); err != nil {
                log.Printf("mark published error: %v", err)
                break
            }
        }
        time.Sleep(1 * time.Second)
    }
}

Async patterns and idempotency

Events can arrive out of order or be retried. Consumers should be idempotent. A simple strategy is to track processed event IDs per consumer. For example, store last processed event ID in Redis with a TTL. This avoids double-processing on retries.

// infrastructure/eventbus/idempotency.go
package eventbus

import (
    "context"
    "fmt"
    "time"

    "github.com/go-redis/redis/v8"
)

type IdempotencyStore struct {
    rdb *redis.Client
}

func (i *IdempotencyStore) MarkProcessed(ctx context.Context, consumer string, eventID string) error {
    key := fmt.Sprintf("events:processed:%s:%s", consumer, eventID)
    return i.rdb.Set(ctx, key, "1", 7*24*time.Hour).Err()
}

func (i *IdempotencyStore) IsProcessed(ctx context.Context, consumer string, eventID string) (bool, error) {
    key := fmt.Sprintf("events:processed:%s:%s", consumer, eventID)
    val, err := i.rdb.Get(ctx, key).Result()
    if err == redis.Nil {
        return false, nil
    }
    if err != nil {
        return false, err
    }
    return val == "1", nil
}

In practice, you might also want to rely on exactly-once semantics from the broker (Kafka’s transactional producers) for high throughput, but at the application level, idempotent handling is the safer default.

Integrations without coupling

External MLS feeds and listing syndication APIs often have inconsistent schemas and rate limits. Use an anti-corruption layer to translate into your canonical domain model.

Here’s a minimal example translating a third-party listing payload into your domain event:

// infrastructure/integrations/mls/translator.go
package mls

import (
    "encoding/json"
    "fmt"
    "time"

    "yourapp/domain/listing"
)

type MLSListing struct {
    MLSID        string  `json:"mls_id"`
    Address      string  `json:"address"`
    Price        float64 `json:"price"`
    Beds         int     `json:"beds"`
    Baths        float64 `json:"baths"`
    SqFt         int     `json:"sq_ft"`
    ListedAt     string  `json:"listed_at"` // ISO 8601
    Provider     string  `json:"provider"`
}

func ToListingCreatedEvent(mls MLSListing, tenantID string) (listing.Event, error) {
    t, err := time.Parse(time.RFC3339, mls.ListedAt)
    if err != nil {
        return listing.Event{}, fmt.Errorf("invalid listed_at: %w", err)
    }
    payload := map[string]interface{}{
        "mls_id":    mls.MLSID,
        "address":   mls.Address,
        "price":     mls.Price,
        "beds":      mls.Beds,
        "baths":     mls.Baths,
        "sq_ft":     mls.SqFt,
        "listed_at": t,
        "provider":  mls.Provider,
    }
    b, _ := json.Marshal(payload)
    return listing.Event{
        ID:          fmt.Sprintf("evt_mls_%s_%d", mls.MLSID, time.Now().UnixNano()),
        Type:        "MLSListingImported",
        AggregateID: fmt.Sprintf("prop_%s", mls.MLSID),
        TenantID:    tenantID,
        Timestamp:   time.Now().UTC(),
        SchemaVer:   "1.0.0",
        Payload:     b,
    }, nil
}

Search index alignment

A practical approach to syncing the search index is to have the search service subscribe to ListingCreated and ListingPriceChanged events and update Elasticsearch documents accordingly. The domain event contains only necessary fields for search, avoiding leaking internal DB schemas.

If you need complex geo queries, ensure your Elasticsearch mapping includes a geo_point field for location and a multi-field for full-text address search. The read model can include denormalized fields like neighborhood, school district, and transit score, populated from separate services.

Strengths and tradeoffs

What this architecture does well

  • Evolvability: Event-driven design allows you to add new consumers without modifying the write path.
  • Auditability: The event log provides an immutable record for compliance and debugging.
  • Scalability: Read models can be scaled independently. Hot endpoints like search can be cached and sharded.
  • Tenant isolation: RLS or schema-per-tenant provides strong guarantees and simplifies audits.
  • Developer experience: Clear boundaries between domain, application, and infrastructure reduce cognitive load and make onboarding easier.

Where it struggles

  • Complexity: Eventual consistency can be confusing for teams used to synchronous flows. Debugging cross-service issues requires tooling (distributed tracing).
  • Data modeling: Time-based domains are tricky. Overusing events can lead to event sprawl; you must enforce schema versioning.
  • Operational overhead: Running Kafka or Postgres logical replication adds ops burden. Without good observability, small failures become invisible.
  • Cost: Indexing and storage costs grow quickly. Unmanaged Elasticsearch can become expensive and slow.

When to choose this approach

  • If you handle transactions, compliance, and multi-tenancy, the event-driven relational core is worth it.
  • If you need robust search and analytics, add CQRS with evented read models.
  • If you’re building a lightweight MVP without complex workflows, a monolith with a simple event publisher might be sufficient and faster to ship.

Personal experience

In one project, we migrated from a shared “listings” table with a tenant_id column to RLS plus an event outbox. The initial pushback was due to perceived complexity. The payoff came during a data incident: a misconfigured report generator tried to pull all listings. With RLS enforced at the database level, the query returned zero results instead of leaking data. That alone justified the extra setup.

Another lesson was around idempotency. Early on, our Kafka consumer processed the same event twice due to a rebalance. We’d missed idempotent updates and ended up with duplicate notifications. Adding Redis-based event tracking solved it, but it also highlighted that event-driven systems are only as robust as the smallest assumption in the consumer. Now, I write consumers with idempotency from day one.

On search alignment, I learned to keep the search index denormalized but purpose-built. In one case, we copied the entire DB schema into Elasticsearch, only to find query performance degraded and schema changes painful. The fix was to define a dedicated search document model driven by UX requirements, not the DB structure. This change improved performance and made schema evolution safer.

Getting started

Workflow and mental model

  • Start with the domain: Map core aggregates and events before writing code. Think in time and transitions.
  • Choose isolation level: Decide between RLS and schema-per-tenant based on compliance needs.
  • Pick event transport: Postgres logical replication is great for starting simple; Kafka is better at scale.
  • Build one service with outbox: Don’t start with a dozen microservices. Build a monolith service that emits events reliably.
  • Add one read model: Start with search. It teaches you CQRS without complex projections.

Tooling setup

For a Go-based backend, you might use:

  • Go 1.21+
  • PostgreSQL 15+
  • Kafka (or Redpanda) for events
  • Elasticsearch/OpenSearch for search
  • Redis for caching and idempotency
  • OpenTelemetry for tracing

Folder structure recap

src/
  api/
    handlers/
    middleware/
  domain/
    listing/
    transaction/
  application/
    commands/
    queries/
  infrastructure/
    eventbus/
    persistence/
    search/
    integrations/
  shared/
cmd/
  server/
  consumer/

Focus on workflows:

  • Local dev: run Postgres and Kafka via docker-compose; run migrations automatically on startup.
  • Testing: unit tests for domain logic, integration tests for outbox and consumer idempotency.
  • Observability: structured logs, trace IDs, and metrics for outbox publishing lag.

What stands out

  • Maintainability: Domain events document behavior better than comments.
  • Outcomes: Faster iteration on read models, fewer production surprises, and a clear audit trail.

Free learning resources

Summary

If you’re building a real estate tech platform that must handle multi-tenancy, transactions, and evolving regulations, a relational core with event-driven services and CQRS is a pragmatic choice. It scales in the right ways and gives you auditability and developer clarity. If your needs are lightweight, start with a monolith and a simple event publisher. For teams dealing with complex workflows and compliance, the investment in domain-driven design and outbox-based eventing pays off in fewer incidents and faster feature delivery.

Use this approach if you value long-term maintainability, need robust search, and operate multi-tenant environments. Skip it if you’re prototyping a single-tenant MVP with minimal compliance overhead or if your team lacks ops capacity to run Kafka and Elasticsearch in production. Either way, keep the domain central, write events that reflect business meaning, and make your read models purpose-built. That’s the path to a system that evolves with the market rather than fighting it.