DEV Community

Cover image for Schema-First Approach with ThingsDB
Jeroen van der Heijden
Jeroen van der Heijden

Posted on

Schema-First Approach with ThingsDB

In many architectures, the separation between database and application code introduces a dangerous gap where data integrity can fail. The application code is responsible for validation, but what if multiple services access the same data?

This post explores a different approach. We'll show you how to leverage ThingsDB's core features—Typed Collections, Procedures, and Rooms—to make the database itself the enforcing authority.

Follow along as we set up a secure, event-driven API for an Order application, demonstrating how to lock down access and ensure every data change is both valid and consistent.

1. Setting the Stage: The Blank Slate

We begin by creating a fresh collection for our application.

(execute the code snippet in the /thingsdb scope)

new_collection('OrderApp');
Enter fullscreen mode Exit fullscreen mode

2. Understanding Typed Collections (The Core Defense)

The first step toward protection is establishing a schema. By default, a new ThingsDB collection starts with an empty, unrestricted object (a basic "thing") at its root. Any user with write access can add anything to it.

We want to lock this down. We create a type, App, to serve as the structured blueprint for the collection's entire existence.

(execute the code snippet in the //OrderApp scope)

// 1. Create a type for the root of my collection
new_type('App');

// 2. Convert the collection root to the 'App' type
.to_type('App');
Enter fullscreen mode Exit fullscreen mode

Why this matters: The collection is now restricted by the App type. No data can be added or modified unless the modification conforms to changes made to the App type definition. This is your primary schema defense.

3. Defining the Data Model

Next, we define the structure of the data we want to store—the Order object—and where to keep it within the App root.

(execute the code snippet in the //OrderApp scope)

// Create an enumerator for consistent order status values
set_enum('OrderStatus', {
    OPEN: 'Open',
    PENDING: 'Pending',
    CLOSED: 'Closed',
});

// Define the Order structure
set_type('Order', {
    id: '#',               // Return ID as `id` when wrapped
    name: 'str',
    price: 'float',
    status: 'OrderStatus', // Enforcing status consistency
    created: 'datetime',
});

// Update the App type: Add a property 'orders' as an array of Order objects
mod_type('App', 'add', 'orders', '[Order]');
Enter fullscreen mode Exit fullscreen mode

The mod_type(..) call automatically creates the orders property as an empty list on the collection root, ready to hold our data.

4. Establishing an Event Channel

For a modern microservice or component-based application, polling for data changes is inefficient. We set up an event channel using a Room to enable real-time communication.

(execute the code snippet in the //OrderApp scope)

// Add a room property to the App type for event handling
mod_type('App', 'add', 'order_events', 'room');

// For easy identification, we give the room a fixed name
.order_events.set_name('api_order_events');
Enter fullscreen mode Exit fullscreen mode

Benefit: Any component can now join this room and instantly react to events like new-order or change-order-status without needing to constantly query the collection.

5. Encapsulating Logic with Procedures (The API Layer)

To guarantee that only valid changes are made to the collection, we expose all actions only through Procedures. We prefix these procedures with api_ for easy security whitelisting later.

(execute the code snippet in the //OrderApp scope)

// 1. Add a new order (with validation on input types)
new_procedure('api_order_add', |name, price| {
    order = Order{
        name:,
        price:,
    };
    .orders.push(order);

    // Emit the 'new-order' event for all listeners
    .order_events.emit('new-order', order.wrap());
    nil;
});

// 2. Change order status
new_procedure('api_order_set_status', |order_id, status| {
    // Input validation: ensures the new status is a valid enum value
    new_status = OrderStatus{||status.upper()};

    // Access and update the specific Order object
    Order(order_id).status = new_status;

    // Emit the 'change-order-status' event
    .order_events.emit('change-order-status', order_id, new_status.value());
    nil;
});

// 3. Retrieve orders by status
new_procedure('api_orders_by_status', |status| {
    order_status = OrderStatus{||status.upper()};
    .orders.filter(|order| order.status == order_status).map_wrap();
});
Enter fullscreen mode Exit fullscreen mode

6. Strict Security: The Locked-Down API User

The final and most crucial step is to create a user that can only execute these specific procedures. This prevents any external service from bypassing your defined business logic.

(execute the code snippet in the /thingsdb scope)

// Create a new user for microservice access
new_user('api');

// Grant access to the collection:
// CHANGE (to modify data), JOIN (to listen to events), RUN (to execute procedures)
grant('//OrderApi', 'api', CHANGE|JOIN|RUN);

// Whitelist: This is the security lock.
// The user can only execute procedures and join rooms starting with 'api_'
whitelist_add('api', 'procedures', /^api_.*/);
whitelist_add('api', 'rooms', /^api_.*/);

// Generate and return the authentication token
new_token('api');
Enter fullscreen mode Exit fullscreen mode

With this setup, the api user is completely restricted. It cannot arbitrarily delete the collection, change its structure, or modify data without going through the validated input and logic defined in your api_ procedures. Your collection is now truly protected by code!

In the next post, we will switch gears to the client side. We will demonstrate how a microservice uses the generated token to connect to the OrderApp collection, how to perform the authenticated api_ queries, and, crucially, how to "listen" to the real-time event changes emitted through the api_order_events room.

Top comments (0)