API
Data Transfer Objects (DTOs)
We use DTOs to decouple the API contract from the domain model. For each domain model that is serialized through the API, there should be a corresponding DTO class. Moreover, there should be DTO classes for nested fields if these are domain models.
For mapping models to DTOs, a separate mapper interface should be created for a model using the
@Mapper annotation provided by MapStruct. As MapStruct is capable of
mapping nested fields automatically to their corresponding DTO classes, mapper interfaces should
only be created for models when they must be directly invoked. For example, CourseMapper maps the
Course entity to CourseDto and automatically maps the contained Term entity to TermDto given
that the term field in Course matches the term field in CourseDto. While there is a
dedicated TermMapper interface, this is not required here. It only exists because other parts of
the code have to directly invoke TermMapper to obtain a TermDto.
When defining mapper interfaces, existing interfaces should be reused by registering them through
the uses attribute of the @Mapper annotation. For example, CourseMapper reuses TermMapper
because it is defined as follows:
@Mapper(
componentModel = "spring",
uses = {TermMapper.class, UserMapper.class, ProgramModuleMapper.class})
public interface CourseMapper {
CourseDto map(Course course);
}
In some cases, polymorphic domain models must be mapped to DTOs. For example, there are various
subclasses of AssessmentProgress, such as BinaryAssessmentProgress and
PointsAssessmentProgress. Then, use @SubclassMapping together with
@BeanMapping(subclassExhaustiveStrategy = SubclassExhaustiveStrategy.RUNTIME_EXCEPTION) to map
them to their corresponding DTO class. This ensures that mapping does not fail silently when
encountering unmapped subtypes. See UnitAssessmentProgressMapper for details.
Controllers and Application Services
Endpoints should be defined by declaring methods in classes annotated with @RestController.
Moreover, controller classes should never access the database, check for permissions, involve domain logic, or perform DTO
mapping. Instead, they should always delegate to an application service. For example,
CourseController#create delegates to CourseService#create, which handles a course creation
request and returns a CourseDto for the created Course entity.
This also affects Optional<X> values, which may represent the values of request parameters. These
should be passed into the service method and unwrapped inside the
service, not the controller.
When there is considerable domain logic involved, define additional domain services and call them
from inside an application service. For example, TaskPoolService is an application service where
syncPool performs permission checking and then delegates to TaskPoolSyncService for performing
the task import.
Error Handling
Error handling follows an aspect-oriented approach. A single @ControllerAdvice
class (GlobalExceptionHandler) intercepts unhandled exceptions raised while processing a
request and translates them into error responses. This requires custom exceptions that are intended to signal API errors (such as
CourseNotFoundException) to be annotated with @ResponseStatus for setting the status code and
reason (a human-readable, general description of the error). When the reason must be built
dynamically from an exception's fields, the exception can provide a dynamic reason by
implementing the ReasonProvider interface. See JoinsExceededException for an example.
Sometimes the status code tied to an exception needs to change for a specific
endpoint. For example, CourseController#create throws a
CourseNotFoundException when the template course referenced in the payload
cannot be found. Here, the status code 400 fits better than the
exception's default status (404). Thus, the controller method is annoted with
@ResponseStatusMapping to remap the status code for that endpoint (see the
JavaDocs for details).
When a client needs to react to a specific error, the exception should be annotated with
@Error to assign it a stable ErrorCode. If the client also needs additional
error details in a machine-readable format, they should be exposed through fields on the
exception class and the class should be annotated with @Expose. Then, the ErrorDataSerializer
component will handle serialization to obtain an appropriate error representation.
Swagger
springdoc generates an OpenAPI schema based on controller and DTO classes and
configures SwaggerUI so that the docs can be accessed at /swagger-ui. Methods that declare
endpoints and DTOs should be documented with springdoc's
JavaDoc support, or with the @Operation and @Parameter
annotations. As springdoc cannot infer the errors an endpoint may return automatically, each
endpoint needs to be annotated with @SubatoApiResponse to explicitly document them.
SubatoApiResponseCustomizer then merges this documentation into the generated OpenAPI schema.
Rate Limiting
To keep clients from monopolizing server resources, requests are rate limited to 100
requests/minute (anonymous users) and 200 requests/minute (authenticated users). Every
response reports the remaining budget in the X-Rate-Limit-Remaining header, and requests that
exceed the budget are rejected with status code 429.