Skip to content
Snippets Groups Projects

Refactor Remote Dev to a single common service class

Merged Chad Woolley requested to merge caw-rd-remove-service-layer into master
@@ -74,7 +74,7 @@ flowchart TB
apis[Grape APIs & GraphQL Resolvers/Mutations]
subgraph service[Remote Development: Service layer]
direction TB
services[Services]
service[Service]
subgraph domainlogiclayer[Remote Development: Domain Logic layer]
domainlogic[Domain Logic modules]
end
@@ -87,11 +87,11 @@ flowchart TB
models[ActiveRecord models]
domainlogic --> otherdomainservices[Other domain services]
controllers --> apis
apis --> services
services --> domainlogic
apis --> service
service --> domainlogic
domainlogic --> models
controllers --> settings
services --> settings
service --> settings
models --> settings
end
```
@@ -331,24 +331,81 @@ See [this MR comment thread](https://gitlab.com/gitlab-org/gitlab/-/merge_reques
Here is an example of Railway Oriented Programming pattern, with extra code removed to focus on the patterns.
First is the Services layer using `ee/lib/remote_development/workspaces/update/main.rb` as an example, which contains no logic other than calling the `Main` class in the Domain Logic layer, and converting the return value to a `ServiceResponse`:
#### API layer code example
```ruby
class UpdateService
attr_reader :current_user
First, you see the `ee/app/graphql/mutations/remote_development/workspaces/update.rb` class
from the API layer. The API classes are not technically part of the ROP pattern,
but we will show a bit of the relevant code from the GraphQL mutation's `#resolve` method,
which is the entry point to invoke the domain logic:
def initialize(current_user:)
@current_user = current_user
```ruby
class Update < BaseMutation
def resolve(id:, **args)
workspace = authorized_find!(id: id)
domain_main_class_args = {
current_user: current_user,
workspace: workspace,
params: args
}
response = ::RemoteDevelopment::CommonService.execute(
domain_main_class: ::RemoteDevelopment::Workspaces::Update::Main,
domain_main_class_args: domain_main_class_args
)
response_object = response.success? ? response.payload[:workspace] : nil
{
workspace: response_object,
errors: response.errors
}
end
end
````
#### Service layer code example
Next is the Service layer class, `ee/app/services/remote_development/common_service.rb`.
You will notice this looks very different than the
[standard Service class pattern found in the monolith](https://docs.gitlab.com/ee/development/reusing_abstractions.html#service-classes):
Since all of our domain logic is in the domain layer and models, the Service layer only has
three responsibilities:
1. Accept the arguments passed from the API layer, and pass them to the correct `Main` class in the Domain Logic layer.
2. Inject additional dependencies, such as [Remote Development Settings](remote-development-settings) and logger, into the Domain Logic layer.
3. Convert the "`response_hash`" return value from the Domain Logic layer into a `ServiceResponse` object.
Given this limited responsiblity and the strictly consistent patterns used in the Domain layer, this means we can use a single, generic
`CommonService` class for the entire domain, and do not need to write (or test) individual service classes for each use case.
The `GitLab::Fp` module stands for "Functional Programming", and contains helper methods used with these patterns.
def execute(workspace:, params:)
response_hash = Update::Main.main(workspace: workspace, current_user: current_user, params: params)
Here's what the `CommonService` class looks like:
```ruby
class CommonService
extend Gitlab::Fp::Helpers
extend ServiceResponseFactory
def self.execute(domain_main_class:, domain_main_class_args:)
main_class_method = retrieve_fp_class_method(domain_main_class)
settings = ::RemoteDevelopment::Settings.get_all_settings
logger = RemoteDevelopment::Logger.build
response_hash = domain_main_class.singleton_method(main_class_method).call(
**domain_main_class_args.merge(settings: settings, logger: logger)
)
create_service_response(response_hash)
end
end
```
#### Domain layer code examples
Next, you see the `ee/lib/remote_development/workspaces/update/main.rb` class, which implements an ROP chain with two steps, `authorize` and `update`.
Note that the `Main` class also has no domain logic in it itself other than invoking the steps and matching the the domain messages and transforming them into a response hash. We want to avoid that coupling, because all domain logic should live in the cohesive classes that are called by `Main` via the ROP pattern:
@@ -425,9 +482,11 @@ There are implementation differences for F# vs Ruby, but the sentiment is the sa
These patterns, especially Railway Oriented Programming, allows us to split the Domain Logic layer more easily into small, loosely coupled, highly cohesive classes. This makes the individual classes and their unit tests easier to write and maintain.
### Minimal logic in Service layer
### No need to write or test service classes
These patterns let all of the Service layer unit test specs be pure mock-based tests, with almost no dependencies on (or testing of) any specifics of the domain logic other than the domain classes' standard API.
There is only a single, generic `CommonService` class used for all use cases - you do not
need to write or test individual Service classes for each use case.
See more details in the [Service layer code example section](#service-layer-code-example).
### More likely that you can use fast_spec_helper
@@ -445,18 +504,21 @@ Also, there are currently several backend engineers on the Remote Development te
## Differences from standard GitLab patterns
### Stateless Service layer classes
### Minimal Service Layer
Some of the services do not strictly follow the [currently documented patterns for the GitLab service layer](https://docs.gitlab.com/ee/development/reusing_abstractions.html#service-classes), because in some cases those patterns don't cleanly apply to all of the Remote Development use cases.
We do not use the [currently documented patterns for the GitLab service layer](https://docs.gitlab.com/ee/development/reusing_abstractions.html#service-classes).
Instead, there is only a single, generic `CommonService` class used for all use cases.
See more details in the [Service layer code example section](#service-layer-code-example).
For example, the reconciliation service does not act on any specific model, or any specific user, therefore it does not have any constructor arguments.
### Stateless classes
In some cases, we do still conform to the convention of passing the current_user
in the constructor of services which do reference the user, although we may change this too in the future to pass it to `#execute` and thus make it a pure function as well.
The usage of these [Functional Patterns](#functional-patterns) means we have entirely
stateless classes in the Domain Logic layer (other than [Value Objects](#value-objects).
We also don't use any of the provided superclasses like BaseContainerService or its descendants, because the service contains no domain logic, and therefore these superclasses and their instance variables are not useful.
This means we use all class ("singleton") methods, no instance methods, and no instance variables,
and each class has a single public method which is the entry point to the class.
If these service classes need to be changed or standardized in the future (e.g. a standardized constructor for all Service classes across the entire application), it will be straightforward to change.
This results in the classes' logic being easier to understand, test, and debug.
### 'describe #method' RSpec blocks are usually unnecessary
Loading