Dwarves
Memo
Type ESC to close search bar

Designing a model with dynamic properties

When we started working on this project aimed at the creative community, we faced an interesting challenge. We needed to build a model that was similar to a task but with a key difference: it had to support custom, dynamic, and extensible properties. If you’ve ever used Notion and appreciated how flexible its objects are, you’ll know exactly what we were trying to achieve.

The expectation

First thing would be extensibility. This wasn’t just about adding simple text fields—we needed the model to handle various types of data, like select options, date-time values, booleans, and even custom relations to other existing models, such as users. Each and every select options should be extensible as well, allowing them to be shared across each team as a common configuration.

Another key consideration was making sure the model was easily query-able. We wanted to be able to sort and paginate through the entire dataset without hitting any roadblocks. This would ensure that as the model grows and evolves, it remains manageable and efficient to work with.

The solution

Here’s a brief overview of what we ended up with (for presentation purposes only):

tasks table

Table tasks {
	id string [pk]
	values string [ref: <> values.id]
}

We started with a very bare-bones model for tasks, which holds a many-to-many relation to the values table. This model doesn’t contain any data of the fields themselves; instead, it relies on the values table to retrieve the relevant field data.

fields table

Table fields {
	id string [pk]
	type string
	name string
	options string[] [ref: <> options.id]
}

The fields table is where the magic begins. The type column can be text, checkbox, select, users, etc., determining how a field’s value should be extracted from values and how it should be handled on the front-end.

For select-type fields, options are saved through a many-to-many relation with the options table.

values table

Table values {
	id string [pk]
	field string [ref: > fields.id]
	text varchar
	checkbox boolean
	select string[] [ref: <> options.id]
	users string[] [ref: <> users.id]
	...
}

This table maps back to fields with a 1-to-1 relationship, using multiple columns to save a field’s value depending on its type.

options table

Table options {
	id string [pk]
	label string
	value string
}

Lastly, the options table stores the available options for select-type fields, linking each option to a field via its ID.

The decisions

Decision 1: Break things down into smaller models

When building an extensible object, separating the object from its fields was essential. By doing this, adding or removing fields became as simple as adding or removing a relation, allowing one task to have a completely different set of fields from another. Select options are also meant to be extensible, so we decided to move them into their own table as well.

Decision 2: tasks should hold relations to values instead of fields

Another crucial decision we made was to have the tasks table hold relations to the values table rather than directly to the fields table. At first glance, it might seem simpler to link tasks directly to fields, but this approach would have limited the flexibility we were aiming for.

By connecting tasks to values instead, we created a system where each task can have its own unique set of values, all while using the same underlying fields. This means that different tasks can share the same field definitions but still hold different data.

Doing things this way might feel a bit counter-intuitive, in the sense that the data flows from tasks → values → fields. However, it allowed us to simplify the relationships in our model. By cutting out the many-to-many relation between tasks and fields, we made the system more efficient. When removing a field, we’re actually just removing one relation from the values table, and the corresponding field goes away along with the value.

The challenges

Filtering & Pagination

One of the main challenges we encountered was filtering and pagination. With our model involving multiple tables—tasks, fields, values, and options—queries often required complex joins. This setup had the potential to cause performance issues, especially as the dataset grew.

We rely heavily on Common Table Expressions (CTEs) to streamline our queries and improve performance. However, this solution still required careful management to ensure that the system remained efficient under heavy loads.

Cross-team collaboration

Another challenge we faced was enabling cross-team collaboration. Since fields are scoped to each team, it became tricky when users needed to collaborate across different teams.

For instance, a field that exists in one team might not be present in another team where they are invited as guests. This limitation made collaboration difficult for our specific use case, as we don’t currently support a unified team workspace.

While we’ve implemented some solutions to mitigate this issue, it remains an ongoing challenge, and we’re still exploring better approaches to handle this scenario.

Conclusion

In the end, the model we designed has been working well for our use cases, providing the flexibility and extensibility we set out to achieve. While there are still areas for improvement, particularly with cross-team collaboration, the foundation we’ve built offers plenty of room to grow. All in all, it’s been a fun journey, tackling challenges and finding creative solutions along the way.