Clean Architecture: Data Sources
Photo by Azamat Esenaliev from Pexels

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, and handling data serialization and deserialization.

Here is an example of a data source:

1import Foundation 2 3struct Todo: Codable, Identifiable { 4 var id: Int? 5 let title: String 6 let completed: Bool 7} 8 9class TodoDataSource { 10 private let baseURL: URL 11 12 init(baseURL: URL) { 13 self.baseURL = baseURL 14 } 15 16 func getTodos() async throws -> [Todo] { 17 let url = baseURL.appendingPathComponent("/todos") 18 let (data, response) = try await URLSession.shared.data(from: url) 19 guard let httpResponse = response as? HTTPURLResponse, 20 httpResponse.statusCode == 200 21 else { 22 throw NSError(domain: "Invalid response", code: 0, userInfo: nil) 23 } 24 25 let todos = try JSONDecoder().decode([Todo].self, from: data) 26 return todos 27 28 29 } 30 31 func saveTodo(_ todo: Todo) async throws { 32 let url = baseURL.appendingPathComponent("/todos") 33 var request = URLRequest(url: url) 34 request.httpMethod = "POST" 35 request.addValue("application/json", forHTTPHeaderField: "Content-Type") 36 let data = try JSONEncoder().encode(todo) 37 request.httpBody = data 38 let (_, response) = try await URLSession.shared.data(for: request) 39 guard let httpResponse = response as? HTTPURLResponse, 40 httpResponse.statusCode == 201 41 else { 42 throw NSError(domain: "Invalid response", code: 0, userInfo: nil) 43 } 44 } 45}

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".

Control and Data flow

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

1import Foundation 2// Define the interface for the TodoDataSource 3protocol TodoDataSource { 4 func getTodos() async throws -> [Todo] 5 func saveTodo(_ todo: Todo) async throws -> Void 6} 7 8// Define the Todo struct 9struct Todo { 10 let id: Int? 11 let title: String 12 let completed: Bool 13} 14 15class TodoDataSourceImpl: TodoDataSource { 16 private let baseURL: URL 17 18 init(baseURL: URL) { 19 self.baseURL = baseURL 20 } 21 22 func getTodos() async throws -> [Todo] { 23 let url = baseURL.appendingPathComponent("/todos") 24 let (data, response) = try await URLSession.shared.data(from: url) 25 guard let httpResponse = response as? HTTPURLResponse, 26 httpResponse.statusCode == 200 27 else { 28 throw NSError(domain: "Invalid response", code: 0, userInfo: nil) 29 } 30 31 let todos = try JSONDecoder().decode([Todo].self, from: data) 32 return todos 33 } 34 35 func saveTodo(_ todo: Todo) async throws { 36 let url = baseURL.appendingPathComponent("/todos") 37 var request = URLRequest(url: url) 38 request.httpMethod = "POST" 39 request.addValue("application/json", forHTTPHeaderField: "Content-Type") 40 let data = try JSONEncoder().encode(todo) 41 request.httpBody = data 42 let (_, response) = try await URLSession.shared.data(for: request) 43 guard let httpResponse = response as? HTTPURLResponse, 44 httpResponse.statusCode == 201 45 else { 46 throw NSError(domain: "Invalid response", code: 0, userInfo: nil) 47 } 48 } 49} 50 51// Usage example 52do { 53 let BASE_URL = URL(string: "https://jsonplaceholder.typicode.com")! 54 let todoDataSource: TodoDataSource = TodoDataSourceImpl(baseURL: BASE_URL) 55 56 // Fetch todos 57 let todos = try await todoDataSource.getTodos() 58 print(todos) 59 60 // Save a todo 61 let todo = Todo(title: "Buy milk", completed: false) 62 try await todoDataSource.saveTodo(todo) 63} catch { 64 print("An error occurred: \(error)") 65} 66

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

1import Foundation 2 3protocol HttpWrapper { 4 func get(_ url: URL) async throws -> Data 5 func post(_ url: URL, data: Data) async throws 6} 7 8class URLSessionWrapper: HttpWrapper { 9 10 func get(_ url: URL) async throws -> Data { 11 let (data, response) = try await URLSession.shared.data(from: url) 12 guard let httpResponse = response as? HTTPURLResponse, 13 httpResponse.statusCode == 200 14 else { 15 throw NSError(domain: "Invalid response", code: 0, userInfo: nil) 16 } 17 return data 18 } 19 20 func post(_ url: URL, data: Data) async throws { 21 var request = URLRequest(url: url) 22 request.httpMethod = "POST" 23 request.addValue("application/json", forHTTPHeaderField: "Content-Type") 24 request.httpBody = data 25 let (_, response) = try await URLSession.shared.data(for: request) 26 guard let httpResponse = response as? HTTPURLResponse, 27 httpResponse.statusCode == 201 28 else { 29 throw NSError(domain: "Invalid response", code: 0, userInfo: nil) 30 } 31 } 32} 33 34protocol TodoDataSource { 35 func getTodos() async throws -> [Todo] 36 func saveTodo(_ todo: Todo) async throws -> Void 37} 38 39class HTTPDataSource: TodoDataSource { 40 41 private let httpWrapper: HttpWrapper 42 private let baseURL: URL 43 44 init(httpWrapper: HttpWrapper, baseURL: URL) { 45 self.httpWrapper = httpWrapper 46 self.baseURL = baseURL 47 } 48 49 func getTodos() async throws -> [Todo] { 50 let url = baseURL.appendingPathComponent("/todos") 51 let data = try await httpWrapper.get(url) 52 let todos = try JSONDecoder().decode([Todo].self, from: data) 53 return todos 54 } 55 56 func saveTodo(_ todo: Todo) async throws { 57 let url = baseURL.appendingPathComponent("/todos") 58 let data = try JSONEncoder().encode(todo) 59 try await httpWrapper.post(url, data: data) 60 } 61 62} 63 64// Usage example 65 66do { 67 let BASE_URL = URL(string: "https://jsonplaceholder.typicode.com")! 68 let todoDataSource = HTTPDataSource(httpWrapper: URLSessionWrapper(), baseURL: BASE_URL) 69 70 // Fetch todos 71 let todos = try await todoDataSource.getTodos() 72 print(todos) 73 74 // Save a todo 75 let todo = Todo(title: "Buy milk", completed: false) 76 try await todoDataSource.saveTodo(todo) 77} catch { 78 print("An error occurred: \(error)") 79}

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 unauthorized 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.