Switching from REST to GraphQL in My Blog with Minimal Code Changes
2025-01-05
2025-01-05
Over time, I’ve shared a few posts about how my blog evolved into the Next.js project it is today. In this post, I want to dive into a recent architectural improvement and explain more about how I seamlessly switched my blog’s data source from a REST API to a GraphQL API by modifying just a handful of files.
This shift was possible thanks to the use of data providers in my project. By consistently interacting with an abstraction layer (activeDataProvider
), I was able to decouple my data-fetching logic from the actual source of the data.
The beauty of this design lies in its simplicity. To change the data provider, all I had to do was:
IDataProvider
interface.activeDataProvider
.That’s it! No need to rewrite logic across multiple components or refactor complex parts of the application.
Here’s what the current iteration of the IDataProvider interface looks like:
export interface IDataProvider {
getAll(options: PostSearchOptions): Promise<PaginatedPosts>;
getOneBySlug(slug: string): Promise<Post | null>;
getPostMetadata(slug: string): Promise<Post | null>;
create?(data: Partial<Post>): Promise<Post>;
update?(slug: string, data: Partial<Post>): Promise<Post | null>;
delete?(slug: string): Promise<boolean>;
}
This interface enforces the core methods required for interacting with my blog’s data—fetching posts, retrieving metadata, and optionally creating, updating, or deleting posts.
BaseDataProvider
To ensure consistency across different data providers, I implemented a BaseDataProvider
class:
This class handles all the standard methods from the interface and introduces an extra layer of abstraction by defining abstract methods that subclasses must implement:
export abstract class BaseDataProvider implements IDataProvider {
create?(data: Partial<Post>): Promise<Post> {
throw new Error('Method not implemented.');
}
update?(slug: string, data: Partial<Post>): Promise<Post | null> {
throw new Error('Method not implemented.');
}
delete?(slug: string): Promise<boolean> {
throw new Error('Method not implemented.');
}
abstract getAllFromStorage(
options: PostSearchOptions,
): Promise<PaginatedPosts>;
abstract getOneBySlugFromStorage(slug: string): Promise<Post | null>;
abstract getPostMetadataFromStorage(slug: string): Promise<Post | null>;
async getAll(options: PostSearchOptions): Promise<PaginatedPosts> {
return new Promise(async (resolve, reject) => {
const paginatedPosts = await this.getAllFromStorage(options);
resolve(paginatedPosts);
});
}
async getOneBySlug(slug: string): Promise<Post | null> {
return new Promise(async (resolve, reject) => {
const matchingPost = await this.getOneBySlugFromStorage(slug);
if (matchingPost) {
resolve(matchingPost);
} else {
resolve(null);
}
});
}
async getPostMetadata(slug: string): Promise<Post | null> {
return new Promise(async (resolve, reject) => {
const matchingPost = await this.getPostMetadataFromStorage(slug);
if (matchingPost) {
resolve(matchingPost);
} else {
resolve(null);
}
});
}
}
These <Something>FromStorage
methods are the real magic—they encapsulate the logic for interacting with the actual data source, whether it’s a REST API, a GraphQL endpoint, or even static files.
On the other hand, the BaseDataProvider
provides the generic methods getAll
, getOneBySlug
and getPostMetadata
. These are the methods that our components interact with directly. Internally, they call the appropriate getAllFromStorage
, getOneBySlugFromStorage
and getPostMetadataFromStorage
methods.
This separation ensures that the specific details of the persistence layer are abstracted away from the components, keeping the architecture clean and decoupled.
In my opinion, decoupling components from external dependencies is a powerful asset that allows us to create more resilient and testable code. While this approach introduces some overhead and requires a shift in mindset, it proves invaluable in fast-paced environments where technologies evolve and change rapidly.
By creating abstraction layers, we can make our code more adaptable, enabling smoother transitions to new tools or data sources without major rewrites. This flexibility ultimately helps future-proof the project and maintain long-term efficiency.
Of course, one could argue that maintaining this level of abstraction is easy in a small project like my blog. But my perspective is this: if I can apply such high coding standards to a personal project that generates no profit and where no one will complain if it breaks tomorrow, why shouldn’t I hold myself to the same (or even higher) standard in my professional work?
In a business environment, where the software directly contributes to revenue and people rely on the services I build, maintaining clean, adaptable, and well-structured code is even more critical.
This is an example of a component tightly coupled to the implementation of the data layer, in this case using Apollo GraphQL:
const PostList: FC = () => {
const { data } = useQuery<PostsData, PostsVars>(GET_POSTS, {
variables: { limit: 10, offset: 0 },
});
return (
<ul>
{data?.posts.map((post) => (
<li key={post.id}>
<h2>{post.title}</h2>
<p>{post.text}</p>
</li>
))}
</ul>
);
};
An one that uses the approach that I propose:
const PostList: FC = () => {
const { data } = await activeDataProvider.getAll(options);
return (
<ul>
{data?.posts.map((post) => (
<li key={post.id}>
<h2>{post.title}</h2>
<p>{post.text}</p>
</li>
))}
</ul>
);
};
This example does not include any imports related to GraphQL or other external dependencies. It relies solely on the activeDataProvider
interface, ensuring the component remains decoupled from the underlying data-fetching implementation.
Another benefit of this approach is how easily a data provider can be replaced by writing unit tests.
Since my app relies on activeDataProvider
, I can easily swap the real data provider with an in-memory mock during unit tests.
In my vitest.setup.ts
file, I added a mock that replaces activeDataProvider with a lightweight, in-memory provider:
// Replace active dataProvider with MemoryDataProvider
vi.mock('./data-providers/active', async () => {
const jsonData = JSON.parse(
readFileSync('./tests/test-data.json', 'utf-8'),
);
return {
default: new MemoryDataProvider(jsonData),
};
});
This mock loads data from a static JSON file during tests, ensuring predictable results without external dependencies.
Testing a component that fetches posts is as simple as calling:
await activeDataProvider.getAll(options);
Since the provider is mocked, the tests run fast, and I can easily simulate various data states (empty results, errors, or populated lists).
During this latest iteration, I transitioned the blog to pull data from a GraphQL API by implementing a new provider class that extends BaseDataProvider
. By implementing the abstract methods (getAllFromStorage
, etc.) with GraphQL queries, the switch was complete.
Now, for example, whenever a component fetches posts, it calls:
await activeDataProvider.getAll(options);
The underlying provider handles the communication with the persistance layer, ensuring the component is agnostic to whether the data came from GraphQL, REST, or elsewhere.
Checkout the complete source code here.