Clean Architecture: Data Sources
Introduction
In the context of Clean Architecture, a data source is a module that provides access to data from external systems such as databases, web services, or file systems. It is responsible for implementing the low-level details of data access, such as opening and closing database connections, executing queries, executing http requests and handling data serialisation and deserialisation.
Here is an example of a data source:
import Foundation
struct Todo: Codable, Identifiable {
var id: Int?
let userId: Int
let title: String
let completed: Bool
}
class TodoDataSource {
private let baseURL: URL
init(baseURL: URL) {
self.baseURL = baseURL
}
func getTodos() async throws -> [Todo] {
let url = baseURL.appendingPathComponent("/todos")
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200
else {
throw NSError(domain: "Invalid response", code: 0, userInfo: nil)
}
let todos = try JSONDecoder().decode([Todo].self, from: data)
return todos
}
func saveTodo(_ todo: Todo) async throws {
let url = baseURL.appendingPathComponent("/todos")
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
let data = try JSONEncoder().encode(todo)
request.httpBody = data
let (_, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 201
else {
throw NSError(domain: "Invalid response", code: 0, userInfo: nil)
}
}
}
The example is in Swift, but you can use the same basic concept in other programming languages too
A data source is an implementation detail of the “Data Layer” or “Infrastructure Layer”, which is the layer that handles communication with external systems. The Infrastructure Layer is isolated from the rest of the system, and its components are not allowed to have any knowledge of or dependency on the higher-level layers, such as the “Domain Layer”.
Data Source Interfaces
It is generally a good idea to have data sources in the infrastructure layer implement interfaces. The reason for this is that by defining an interface for the data source, you can abstract away the implementation details and create a clear separation of concerns between the data source and other modules.
Consumers of the data source should not be concerned with the specifics of how data is stored or retrieved. By defining an interface for the data source, you can ensure consumers only interacts with the data through a set of well-defined methods, which makes it easier to test and maintain the code.
Another advantage of using interfaces is that it makes it easier to switch out one implementation of a data source for another. For example, if you want to switch from using a relational database to a document database, you can create a new implementation of the data source that implements the same interface and swap it out without affecting any other modules.
Let’s update the data source
import Foundation
// Define the interface for the TodoDataSource
protocol TodoDataSource {
func getTodos() async throws -> [Todo]
func saveTodo(_ todo: Todo) async throws -> Void
}
// Define the Todo struct
struct Todo {
let id: Int?
let title: String
let completed: Bool
}
class TodoDataSourceImpl: TodoDataSource {
private let baseURL: URL
init(baseURL: URL) {
self.baseURL = baseURL
}
func getTodos() async throws -> [Todo] {
let url = baseURL.appendingPathComponent("/todos")
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200
else {
throw NSError(domain: "Invalid response", code: 0, userInfo: nil)
}
let todos = try JSONDecoder().decode([Todo].self, from: data)
return todos
}
func saveTodo(_ todo: Todo) async throws {
let url = baseURL.appendingPathComponent("/todos")
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
let data = try JSONEncoder().encode(todo)
request.httpBody = data
let (_, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 201
else {
throw NSError(domain: "Invalid response", code: 0, userInfo: nil)
}
}
}
// Usage example
do {
let BASE_URL = URL(string: "https://jsonplaceholder.typicode.com")!
let todoDataSource: TodoDataSource = TodoDataSourceImpl(baseURL: BASE_URL)
// Fetch todos
let todos = try await todoDataSource.getTodos()
print(todos)
// Save a todo
let todo = Todo(title: "Buy milk", completed: false)
try await todoDataSource.saveTodo(todo)
} catch {
print("An error occurred: \(error)")
}
In this example, we first define the Todo struct that represents a single todo item. We then define the TodoDataSource interface, which has two methods: getTodos and saveTodo. These methods both use async/await to perform their operations and throw errors if something goes wrong.
Next, we define the TodoDataSourceImpl class that implements the TodoDataSource interface. The getTodos method fetches the todos, while the saveTodo method creates a new todo.
Finally, we show an example of how to use the TodoDataSourceImpl class by creating an instance of it and calling its getTodos and saveTodo methods.
Wrappers
It can be useful to use database wrappers within our data sources for a few reasons:
- Encapsulation: Wrappers provide an additional layer of encapsulation between the application and the infrastructure. This can help to protect the application from changes to implementation details, and it can also make it easier to switch to a different library if needed.
- Abstraction: Wrappers can provide a higher-level abstraction of the functionality, which can make it easier to use and work with. For example, an HTTP wrapper may provide methods for commonly used operations like (GET, PUT, POST, DELETE).
- Testing: Wrappers can make it easier to write tests for infrastructure-related code. For example, a wrapper may provide a way to mock the infrastructure or third party library during testing, which can make tests faster and more reliable.
- Security: Wrappers can also help to improve the security of the application by providing a layer of protection against things like SQL injection attacks, which can occur when user input is not properly sanitised.
Let’s update the code one more time to include a wrapper
import Foundation
protocol HttpWrapper {
func get(_ url: URL) async throws -> Data
func post(_ url: URL, data: Data) async throws
}
class URLSessionWrapper: HttpWrapper {
func get(_ url: URL) async throws -> Data {
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200
else {
throw NSError(domain: "Invalid response", code: 0, userInfo: nil)
}
return data
}
func post(_ url: URL, data: Data) async throws {
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = data
let (_, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 201
else {
throw NSError(domain: "Invalid response", code: 0, userInfo: nil)
}
}
}
protocol TodoDataSource {
func getTodos() async throws -> [Todo]
func saveTodo(_ todo: Todo) async throws -> Void
}
class HTTPDataSource: TodoDataSource {
private let httpWrapper: HttpWrapper
private let baseURL: URL
init(httpWrapper: HttpWrapper, baseURL: URL) {
self.httpWrapper = httpWrapper
self.baseURL = baseURL
}
func getTodos() async throws -> [Todo] {
let url = baseURL.appendingPathComponent("/todos")
let data = try await httpWrapper.get(url)
let todos = try JSONDecoder().decode([Todo].self, from: data)
return todos
}
func saveTodo(_ todo: Todo) async throws {
let url = baseURL.appendingPathComponent("/todos")
let data = try JSONEncoder().encode(todo)
try await httpWrapper.post(url, data: data)
}
}
// Usage example
do {
let BASE_URL = URL(string: "https://jsonplaceholder.typicode.com")!
let todoDataSource = HTTPDataSource(httpWrapper: URLSessionWrapper(), baseURL: BASE_URL)
// Fetch todos
let todos = try await todoDataSource.getTodos()
print(todos)
// Save a todo
let todo = Todo(title: "Buy milk", completed: false)
try await todoDataSource.saveTodo(todo)
} catch {
print("An error occurred: \(error)")
}
In this example, we’re using the URLSessionWrapper
wrapper in our HTTPDataSource
data source. The getTodos
method of HTTPDataSource
uses the get
method of the HTTPWrapper
to fetch data. The saveTodos
method of HTTPDataSource
uses the post
method of the HTTPWrapper
to send data.
Conclusion
In conclusion, a data source is important for storing and retrieving data in an organised way. To make sure that the data source is easy to use, reliable, and easy to maintain, there are some important things to keep in mind:
- Keep it separate from the rest of the application so it’s easy to change if needed.
- Make sure it works with different tools and software, so it can be used in different environments.
- Test it to make sure it works correctly, and make changes as needed to make it better.
- Make sure the data stored in it follows the rules and requirements of the application.
- Keep it safe from unauthorised access by using security measures like passwords and encryption.
By following these principles, a data source in Clean Architecture can help make software more reliable, flexible, and secure.