# How Macro Implements CRM Functionality with Custom Properties on Contacts and Companies

> Learn how Macro implements CRM functionality using custom properties on contacts and companies. Extend entities with user-defined schema and keep core tables clean.

- Repository: [Macro/macro](https://github.com/macro-inc/macro)
- Tags: internals
- Published: 2026-08-18

---

**Macro implements CRM functionality through an entity-agnostic property system that stores custom fields in a dedicated `entity_properties` table, enabling teams to extend contacts and companies with user-defined schema while keeping core CRM tables clean.**

The architecture separates static entity data from dynamic attributes, allowing the same property infrastructure to power CRM custom fields, document tags, and email labels. This design leverages Rust-based Axum routers for API handling and provides TypeScript SDK tools for frontend integration.

## Entity-Agnostic Data Model for Custom Properties

### Core CRM Tables vs. Dynamic Property Storage

The foundation rests on two distinct storage layers. The **core CRM tables**—`crm_companies` and `crm_contacts`—maintain essential fields such as `name`, `email`, `hidden` flags, and timestamps. These tables never mutate when users add custom fields.

Dynamic values reside in the `entity_properties` table, which links a `property_definition_id` (from `property_definitions`) to an `entity_id` and `entity_type` (either `company` or `contact`). This polymorphic association enables the same schema to attach arbitrary key-value pairs to any CRM entity without table migrations.

### Team-Scoped Display Names

For display-name overrides that preserve global directory integrity, Macro adds a `custom_name` column to `crm_companies` (introduced in migration [`20260721185457_crm_company_name.sql`](https://github.com/macro-inc/macro/blob/main/20260721185457_crm_company_name.sql)). The UI resolves visible names using `COALESCE(custom_name, ...)` to ensure the global directory record remains immutable while allowing team-specific labeling.

## Setting and Retrieving Custom Properties

### The SetEntityProperty Tool

The primary interface for writing custom fields is the **`SetEntityProperty`** tool, implemented in [`crates/properties/src/inbound/toolset/set_entity_property.rs`](https://github.com/macro-inc/macro/blob/main/crates/properties/src/inbound/toolset/set_entity_property.rs). This tool accepts an `entity_type`, `entity_id`, `property_definition_id`, and value, then persists the data to `entity_properties`.

```rust
use macro_properties::SetEntityProperty;

// Set a custom "Industry" field on a company
properties_tool.set_entity_property(SetEntityProperty {
    entity_type: EntityType::Company,
    entity_id: company_id,
    property_definition_id: prop_def_uuid,
    value: Some("FinTech".to_string()),
    ..Default::default()
}).await?;

```

### Querying Properties with GetEntityProperties

To read both system and custom fields, the **`GetEntityProperties`** tool aggregates values from `entity_properties` alongside built-in attributes like Stage, Owner, and Revenue. This ensures the API returns a unified property bag regardless of whether a field is native or user-defined.

```rust
let props = properties_tool.get_entity_properties(GetEntityProperties {
    entity_type: EntityType::Contact,
    entity_id: contact_id,
    ..Default::default()
}).await?;

```

## API Layer and Routing

Axum routers expose REST endpoints for entity modification. The **`POST /crm/company/:company_id/name`** endpoint (handled by [`crates/crm/src/inbound/axum_router/set_company_name.rs`](https://github.com/macro-inc/macro/blob/main/crates/crm/src/inbound/axum_router/set_company_name.rs)) and its contact counterpart ([`set_contact_name.rs`](https://github.com/macro-inc/macro/blob/main/set_contact_name.rs)) delegate to domain repositories.

These handlers invoke **`CompaniesRepository`** and **`ContactsRepository`** methods that execute SQL updates. For standard fields, they write directly to `crm_companies` or `crm_contacts`; for custom properties, they invoke the property service layer.

```rust
// Direct repository usage for team-scoped renaming
let repo = CompaniesRepository::new(pool);
repo.set_company_custom_name(&team_id, &company_id, "Acme (ours)", false).await?;

```

## Permission and Access Control

Access enforcement relies on Axum extractors **`CrmCompanyAccessLevelExtractor`** and **`CrmContactAccessLevelExtractor`**. These middleware components validate that the requesting team member possesses appropriate rights before permitting reads or modifications, including access to hidden entities marked with the `hidden` flag.

## Integration with the Property Service

The **properties** crate provides a generic infrastructure reused across the Macro platform. While CRM supplies `entity_type` values of `company` or `contact`, the identical `SetEntityProperty` implementation powers task tagging, email labeling, and document metadata. This consolidation eliminates code duplication and ensures consistent validation logic across all custom-field operations.

## Back-Fill and Data Synchronization

When a team enables CRM via the **`EnableCrm`** endpoint, an asynchronous back-fill job executes. This process creates corresponding rows in `crm_companies`, `crm_contacts`, and `crm_contact_sources` for all existing email activity, guaranteeing that every historical email address receives a contact record without manual data entry.

## TypeScript SDK Implementation

Frontend clients interact with custom properties through the generated SDK located in [`packages/sdk/generated/properties/sdk.gen.ts`](https://github.com/macro-inc/macro/blob/main/packages/sdk/generated/properties/sdk.gen.ts).

```typescript
import { client } from '@macro/sdk';

// Add a custom tag to a contact
await client.setEntityProperty({
  entity_type: "contact",
  entity_id: contactUuid,
  property_definition_id: tagSetDefId,
  add_option_ids: [newTagOptionId],
});

```

The TypeScript definitions in [`packages/sdk/generated/properties/types.gen.ts`](https://github.com/macro-inc/macro/blob/main/packages/sdk/generated/properties/types.gen.ts) provide full type safety for `entity_type` literals and property value shapes.

## Summary

- **Macro stores custom CRM properties in `entity_properties`**, linking them to `crm_companies` and `crm_contacts` via `entity_type` and `entity_id` polymorphic keys.
- **Team-specific display names** use the `custom_name` column with `COALESCE` resolution to avoid mutating global directory records.
- **The `SetEntityProperty` and `GetEntityProperties` tools** provide the primary read/write interface, implemented in `crates/properties/src/inbound/toolset/`.
- **Axum routers** ([`set_company_name.rs`](https://github.com/macro-inc/macro/blob/main/set_company_name.rs), [`set_contact_name.rs`](https://github.com/macro-inc/macro/blob/main/set_contact_name.rs)) handle HTTP ingress, delegating to **`CompaniesRepository`** for SQL execution.
- **Permission extractors** enforce team-level access control before any CRM data modification.
- **Back-fill jobs** automatically generate contact records from historical email data when CRM is activated.

## Frequently Asked Questions

### How are custom properties stored in Macro's CRM?

Custom properties are stored in the `entity_properties` table, which contains columns for `property_definition_id`, `entity_id`, `entity_type` (either `company` or `contact`), and the value. This separates dynamic user-defined fields from the static schema of `crm_companies` and `crm_contacts`, allowing arbitrary schema extensions without database migrations.

### What is the difference between the `custom_name` column and `entity_properties`?

The `custom_name` column on `crm_companies` is a native database field specifically for team-scoped display name overrides, resolved via `COALESCE(custom_name, original_name)` in queries. In contrast, `entity_properties` stores arbitrary custom fields (like "Industry" or "Priority") defined by users through the property system, linked via foreign keys rather than native columns.

### How does Macro handle permissions for CRM entities?

Macro uses Axum extractor middleware—specifically `CrmCompanyAccessLevelExtractor` and `CrmContactAccessLevelExtractor`—to validate team membership and access levels before executing read or write operations. These extractors ensure that only authorized users can view hidden entities or modify custom properties.

### Can custom properties be used for entities other than contacts and companies?

Yes. The **`SetEntityProperty`** tool in [`crates/properties/src/inbound/toolset/set_entity_property.rs`](https://github.com/macro-inc/macro/blob/main/crates/properties/src/inbound/toolset/set_entity_property.rs) is entity-agnostic. While CRM uses `entity_type` values of `company` and `contact`, the same tool powers custom fields for tasks, emails, and documents throughout the Macro platform, ensuring consistent behavior across all entity types.