Selective serialization using serialization groups
When you ever develop an API in the Symfony framework1, you probably heard of the serializationGroup
option. It allows you to define different groups of attributes to serialize and deserialize. However, defining these groups without proper consideration may make the whole development hard, e.g., if you need to deal with different scopes of the object’s attributes in many other endpoints.
I want to show you our approach to serialization groups in one of our projects. Together with my team, we use them to control the serialization of related entities on various endpoints in our API.
Context
It’ll be more comfortable if I provide some context. Instead of talking about entities from the actual project, I’ll use a generic example: Order and Product.
Our case is a RESTish2 API for a relatively small application. Some endpoints respond with entities with a relation or collection of other related entities to avoid unnecessary calls. To generate responses, we use the Serializer component. For handling requests, we rely on Symfony Form component3.
The main challenge is to return mandatory data for each scenario.
-
If the user calls for the Products collection, he gets a collection with basic data about each Product.
-
If the user calls for a specific Product, he gets very detailed data about the requested Product.
But it’s not all.
-
If the user calls for the Orders collection, he gets a collection with a basic data about each Order. Each order has a collection containing basic data about each Product in the order.
-
If the user calls for specific Order, he gets very detailed data about the requested Order. Each order has a collection containing basic data about each Product in the order.
Product and Order are only examples. Imagine many different cases where you need to attach some extra data to a resource, but you don’t want to connect the whole object. Sometimes you don’t need to show the related items at all. Another time, in the case of other endpoints, you need to include very detailed data.
Two groups for entities are not enough
Our first solution consisted of two groups: basic and extend.
We classified each property for one of them, and they were mutually exclusive – a single field should be classified either as basic or extend. Unfortunately, this setup didn’t let us configure the properties of nested resources properly. Whenever we wanted to retrieve a full set of properties from the primary resource and some basic data from related entities, we always wind up with an extend version on each entity.
Two groups for each entity individually
To have more control over the serialization, we decided to use an entity name as a prefix for each serialization group. For Order and Product entities, it looks as follows:
order_basic
order_extend
product_basic
product_extend
Using this approach, we can serialize, e.g., the full set of properties from Order and only basic properties from its assigned Products.
Separate group for relationship
So, we split properties in each entity into basic and extend groups, but where does the collection of products belong?
Basically, it depends on the use case. However, we came up with another convention for all types of relations between entities for the sake of flexibility. Each association has assigned a dedicated group where the name consists of both owner and related entity name, e.g., order_with_products
.
class Order
{
/**
* @Serializer\Groups({"order_basic"})
*/
private $id;
/**
* @Serializer\Groups({"order_extend"})
*/
private $createdAt;
/**
* @Serializer\Groups({"order_with_products"})
*/
private $products;
// rest method omitted
}
How we use it
Thanks to this separation of groups, we could compose responses using specific subset of properties from entities and related objects.
So, come back to the scenarios I use in the Context section. Let’s compose proper responses for each of them. I won’t describe all entities – details aren’t needed here. The @Rest/View
annotation comes from the FOSRestBundle.
First, ProductController:
class ProductController extends AbstractController
{
/**
* @Rest/View(serializationGroups={"product_basic"})
**/
public function showAllProducts()
{
return $this->repository->findAll();
}
/**
* @Rest/View(serializationGroups={"product_basic", "product_extends"})
**/
public function showProduct(Product $product)
{
return $product;
}
}
Next, OrderController:
class OrderController extends AbstractController
{
/**
* @Rest/View(serializationGroups={
* "order_basic",
* "order_with_products",
* "product_basic"
* })
**/
public function showAllOrders()
{
return $this->repository->findAll();
}
/**
* @Rest/View(serializationGroups={
* "order_basic",
* "order_extend",
* "order_with_products",
* "product_basic"
* })
**/
public function showOrder(Order $order)
{
return $order;
}
}
Summary
The serializationGroup
option is powerful, not only when it comes to generating responses like this but also when dealing with different responses that depend on the user’s role or status. There’re a lot of cases when we can use it.
I don’t know how it’ll work in a bigger project with many more entities. Still, in my opinion, as long as the relations between entities are carefully designed, everything should be manageable. In our case, it does the job.
If you have more experience with it, or if you follow another approach, let me know. I’m curious how people use it in their projects.
Featured photo by Tamara Gak on Unsplash.