Repository: turingarena/turingarena (language: python)

Issue:
Refactor ScoreField to avoid custom resolvers



Discussion / hints:

Fix patch:
diff --git a/docs/GraphQL usage.md b/docs/GraphQL usage.md
deleted file mode 100644
index 17327c505..000000000
--- a/docs/GraphQL usage.md
+++ /dev/null
@@ -1,106 +0,0 @@
-# GraphQL Usage
-
-## Fragment used
-
- ### `Text`
-```
-variant
-```
-
- ### `TopBar`
- ```
-title {
-      ...Text
-    }
-    user {
-      id
-      name
-    }
-```
-
-### `MediaInline`
-```
-variant {
-      name
-      type
-      url
-    }
-```
-
-
- ### `ContestView`
- ```
-contest {
-      id
-      title {
-        ...Text
-      }
-      statement {
-        ...MediaInline
-      }
-    }
-
-    problemSetView {
-      assignmentViews {
-        assignment {
-          id
-          problem {
-            id
-            name
-            statement {
-              ...MediaInline
-            }
-          }
-        }
-        ...ContestProblemAssignmentViewAside
-      }
-    }
-
-    ...ContestViewAside
-  }
-```
-
-
-## `contest-problem-assignment-user-tackling-aside.tsx`
-```
-canSubmit
-    submissions {
-      id
-      officialEvaluation {
-        status
-      }
-    }
-
-    ...ContestProblemAssignmentUserTacklingSubmitModal
-    ...ContestProblemAssignmentUserTacklingSubmissionList
-}
-```
-
-## `contest-problem-assignment-user-tackling-submission-list.tsx`
-```
-
-```
-
-## ``
-```
-
-```
-
-## ``
-```
-
-```
-
-## ``
-```
-
-```
-
-## `MainView.ts`
-```
-...TopBar
-contestView {
-    ...ContestView
-}
-```
-
diff --git a/server/src/cli/import.ts b/server/src/cli/import.ts
index 886650d2c..3050b8d48 100644
--- a/server/src/cli/import.ts
+++ b/server/src/cli/import.ts
@@ -1,6 +1,6 @@
 import * as fs from 'fs';
 import * as path from 'path';
-import { ContestApi, ContestData } from '../core/contest';
+import { Contest, ContestData } from '../core/contest';
 import { ArchiveApi } from '../core/files/archive';
 import { ApiObject } from '../main/api';
 
@@ -18,11 +18,6 @@ export class ContestImportApi extends ApiObject {
             archiveId: contestArchiveId,
         });
 
-        console.log(
-            await this.ctx.api(ContestApi).getMetadata({
-                __typename: 'Contest',
-                id: contest.id,
-            }),
-        );
+        console.log(await new Contest(contest.id, this.ctx).getMetadata());
     }
 }
diff --git a/server/src/cli/submit.ts b/server/src/cli/submit.ts
index 6bfb84c8a..d8ec3b486 100644
--- a/server/src/cli/submit.ts
+++ b/server/src/cli/submit.ts
@@ -1,5 +1,5 @@
 import { ContestApi } from '../core/contest';
-import { SubmitApi } from '../core/submit';
+import { Submit } from '../core/submit';
 import { ApiObject } from '../main/api';
 
 export class LocalSubmitApi extends ApiObject {
@@ -14,13 +14,14 @@ export class LocalSubmitApi extends ApiObject {
      */
     async submitLocalFile(username: string, contestName: string, problemName: string, solutionPath: string) {
         const contest = await this.ctx.api(ContestApi).byNameLoader.load(contestName);
-        await this.ctx.api(SubmitApi).submit(
+        await Submit.submit(
             {
                 username,
                 contestId: contest.id,
                 problemName,
                 files: [],
             },
+            this.ctx,
             solutionPath,
         );
     }
diff --git a/server/src/core/achievement.ts b/server/src/core/achievement.ts
index 6d373b4a7..e1d36e12f 100644
--- a/server/src/core/achievement.ts
+++ b/server/src/core/achievement.ts
@@ -1,12 +1,13 @@
 import { gql } from 'apollo-server-core';
 import { AllowNull, Column, ForeignKey, PrimaryKey, Table } from 'sequelize-typescript';
 import { ApiObject } from '../main/api';
+import { ApiContext } from '../main/api-context';
 import { BaseModel, createSimpleLoader } from '../main/base-model';
-import { Evaluation, EvaluationApi } from './evaluation';
+import { Evaluation, EvaluationCache } from './evaluation';
 import { FulfillmentGrade } from './feedback/fulfillment';
 import { ScoreGrade, ScoreGradeDomain } from './feedback/score';
 import { ProblemMaterialApi } from './material/problem-material';
-import { SubmissionApi } from './submission';
+import { Submission } from './submission';
 
 export const achievementSchema = gql`
     type Achievement {
@@ -32,33 +33,33 @@ export class Achievement extends BaseModel<Achievement> {
 
     @Column
     grade!: number;
+
+    getScoreGrade({ scoreRange }: ScoreGradeDomain): ScoreGrade {
+        return new ScoreGrade(scoreRange, this.grade);
+    }
+
+    async getAward(ctx: ApiContext) {
+        const evaluation = await ctx.api(EvaluationCache).byId.load(this.evaluationId);
+        const submission = Submission.fromId(evaluation.submissionId, ctx);
+        const { assignment } = await submission.getTackling();
+        const material = await ctx.api(ProblemMaterialApi).dataLoader.load(assignment.problem.id());
+
+        return material.awards[this.awardIndex];
+    }
+
+    getFulfillmentGrade(): FulfillmentGrade {
+        return new FulfillmentGrade(this.grade === 1);
+    }
 }
 
 export interface AchievementModelRecord {
     Achievement: Achievement;
 }
 
-export class AchievementApi extends ApiObject {
+export class AchievementCache extends ApiObject {
     allByEvaluationId = createSimpleLoader((evaluationId: string) =>
         this.ctx.table(Achievement).findAll({
             where: { evaluationId },
         }),
     );
-
-    async getAward(a: Achievement) {
-        const evaluation = await this.ctx.api(EvaluationApi).byId.load(a.evaluationId);
-        const submission = this.ctx.api(SubmissionApi).fromId(evaluation.submissionId);
-        const { assignment } = await this.ctx.api(SubmissionApi).getTackling(submission);
-        const material = await this.ctx.api(ProblemMaterialApi).dataLoader.load(assignment.problem);
-
-        return material.awards[a.awardIndex];
-    }
-
-    getScoreGrade(a: Achievement, { scoreRange }: ScoreGradeDomain): ScoreGrade {
-        return new ScoreGrade(scoreRange, a.grade);
-    }
-
-    getFulfillmentGrade(a: Achievement): FulfillmentGrade {
-        return new FulfillmentGrade(a.grade === 1);
-    }
 }
diff --git a/server/src/core/auth.ts b/server/src/core/auth.ts
index d14c50497..6edadea03 100644
--- a/server/src/core/auth.ts
+++ b/server/src/core/auth.ts
@@ -2,8 +2,8 @@ import { gql } from 'apollo-server-core';
 import { sign, verify } from 'jsonwebtoken';
 import { ApiEnvironment, LocalApiContext } from '../main/api-context';
 import { loadConfig } from '../main/config';
-import { ContestApi } from './contest';
-import { User, UserApi } from './user';
+import { Contest } from './contest';
+import { User, UserCache } from './user';
 
 export const authSchema = gql`
     type AuthResult {
@@ -45,13 +45,13 @@ export class AuthService {
      */
     async logIn(token: string): Promise<AuthResult | null> {
         // FIXME: assuming only one contest here
-        const contest = await this.ctx.api(ContestApi).getDefault();
+        const contest = await Contest.getDefault(this.ctx);
         if (contest === null) return null;
 
-        const user = await this.ctx.api(UserApi).getUserByToken(contest, token);
+        const user = await contest.getUserByToken(token);
         if (user === null) return null;
         //Get the role of the user. If the role is undefined it will use 'user' isntread
-        let role = (await this.ctx.api(UserApi).metadataLoader.load(user)).role;
+        let role = (await this.ctx.api(UserCache).metadataLoader.load(user.id())).role;
         if (role === undefined) role = 'user';
         const payload: TokenPayload = { contestId: contest.id, username: user.username, role };
 
@@ -61,12 +61,12 @@ export class AuthService {
     async auth(token: string) {
         const payload = verify(token, this.ctx.environment.config.secret) as TokenPayload;
 
-        const contest = await this.ctx.api(ContestApi).getDefault();
+        const contest = await Contest.getDefault(this.ctx);
         if (contest === null || contest.id !== payload.contestId) {
             throw new Error(`token not valid for current contest`);
         }
 
-        const user = await this.ctx.api(UserApi).validate({ __typename: 'User', contest, username: payload.username });
+        const user = await new User(contest, payload.username, this.ctx).validate();
 
         return user;
     }
diff --git a/server/src/core/evaluate.ts b/server/src/core/evaluate.ts
index f451161c4..5c2f06742 100644
--- a/server/src/core/evaluate.ts
+++ b/server/src/core/evaluate.ts
@@ -5,9 +5,9 @@ import { bufferTime, concatAll, concatMap, toArray } from 'rxjs/operators';
 import { ApiObject } from '../main/api';
 import { ContestApi } from './contest';
 import { Evaluation, EvaluationStatus } from './evaluation';
-import { EvaluationEvent, EvaluationEventApi } from './evaluation-event';
+import { EvaluationEvent } from './evaluation-event';
 import { ArchiveApi } from './files/archive';
-import { Submission, SubmissionApi } from './submission';
+import { Submission } from './submission';
 
 export class EvaluateApi extends ApiObject {
     /**
@@ -24,15 +24,15 @@ export class EvaluateApi extends ApiObject {
             status: EvaluationStatus.PENDING,
         });
 
-        const { assignment } = await this.ctx.api(SubmissionApi).getTackling(submission);
-        const { archiveId } = await this.ctx.api(ContestApi).dataLoader.load(assignment.problem.contest);
+        const { assignment } = await submission.getTackling();
+        const { archiveId } = await this.ctx.api(ContestApi).dataLoader.load(assignment.problem.contest.id);
         const contestDir = await this.ctx.api(ArchiveApi).extractArchive(archiveId);
 
         const problemDir = path.join(contestDir, assignment.problem.name);
 
-        const submissionPath = await this.ctx
-            .api(SubmissionApi)
-            .extract(submission, path.join(this.ctx.environment.config.cachePath, 'submission'));
+        const submissionPath = await submission.extract(
+            path.join(this.ctx.environment.config.cachePath, 'submission'),
+        );
 
         const filepath = fs.readdirSync(submissionPath)[0];
         const solutionPath = path.join(submissionPath, filepath);
@@ -79,7 +79,7 @@ export class EvaluateApi extends ApiObject {
 
         if (output.code === 0) {
             for (const event of events) {
-                await this.ctx.api(EvaluationEventApi).storeAchievements(event);
+                await event.storeAchievements(this.ctx);
             }
             await evaluation.update({
                 status: EvaluationStatus.SUCCESS,
diff --git a/server/src/core/evaluation-event.ts b/server/src/core/evaluation-event.ts
index 28835d36a..f96b2e21a 100644
--- a/server/src/core/evaluation-event.ts
+++ b/server/src/core/evaluation-event.ts
@@ -2,6 +2,7 @@ import { UIMessage } from '@edomora97/task-maker';
 import { DataTypes } from 'sequelize';
 import { AllowNull, Column, ForeignKey, Table } from 'sequelize-typescript';
 import { ApiObject } from '../main/api';
+import { ApiContext } from '../main/api-context';
 import { createSimpleLoader, UuidBaseModel } from '../main/base-model';
 import { Achievement } from './achievement';
 import { Evaluation } from './evaluation';
@@ -20,20 +21,12 @@ export class EvaluationEvent extends UuidBaseModel<EvaluationEvent> {
     @AllowNull(false)
     @Column(DataTypes.JSON)
     data!: TaskMakerEvent;
-}
-
-export class EvaluationEventApi extends ApiObject {
-    allByEvaluationId = createSimpleLoader((evaluationId: string) =>
-        this.ctx.table(EvaluationEvent).findAll({
-            where: { evaluationId },
-        }),
-    );
 
-    async storeAchievements(e: EvaluationEvent) {
-        if (typeof e.data === 'object' && 'IOISubtaskScore' in e.data) {
-            const { subtask, normalized_score, score } = e.data.IOISubtaskScore;
-            await this.ctx.table(Achievement).create({
-                evaluationId: e.evaluationId,
+    async storeAchievements(ctx: ApiContext) {
+        if (typeof this.data === 'object' && 'IOISubtaskScore' in this.data) {
+            const { subtask, normalized_score, score } = this.data.IOISubtaskScore;
+            await ctx.table(Achievement).create({
+                evaluationId: this.evaluationId,
                 awardIndex: subtask,
                 // Store the normalized score if the max score is zero (FIXME: ugly hack)
                 grade: score === 0 ? normalized_score : score,
@@ -41,3 +34,11 @@ export class EvaluationEventApi extends ApiObject {
         }
     }
 }
+
+export class EvaluationEventCache extends ApiObject {
+    allByEvaluationId = createSimpleLoader((evaluationId: string) =>
+        this.ctx.table(EvaluationEvent).findAll({
+            where: { evaluationId },
+        }),
+    );
+}
diff --git a/server/src/core/evaluation.ts b/server/src/core/evaluation.ts
index 2eebf768b..39e58c795 100644
--- a/server/src/core/evaluation.ts
+++ b/server/src/core/evaluation.ts
@@ -57,7 +57,7 @@ export interface EvaluationModelRecord {
     Evaluation: Evaluation;
 }
 
-export class EvaluationApi extends ApiObject {
+export class EvaluationCache extends ApiObject {
     byId = createByIdDataLoader(this.ctx, Evaluation);
     allBySubmissionId = createSimpleLoader((submissionId: string) =>
         this.ctx.table(Evaluation).findAll({
diff --git a/server/src/core/feedback/fulfillment.ts b/server/src/core/feedback/fulfillment.ts
index 75568111d..9404b8503 100644
--- a/server/src/core/feedback/fulfillment.ts
+++ b/server/src/core/feedback/fulfillment.ts
@@ -1,6 +1,4 @@
 import { gql } from 'apollo-server-core';
-import { Resolvers } from '../../main/resolver-types';
-import { Valence } from './valence';
 
 export const fulfillmentSchema = gql`
     "A grade expressed as a boolean value: fulfilled or not."
@@ -30,13 +28,21 @@ export const fulfillmentSchema = gql`
 `;
 
 export class FulfillmentGrade {
-    constructor(readonly fulfilled: boolean) {}
+    constructor(readonly fulfilled: boolean | null) {}
+    __typename = 'FulfillmentGrade';
+    valence() {
+        return this.fulfilled === null ? null : this.fulfilled ? 'SUCCESS' : 'FAILURE';
+    }
 }
 
-export class FulfillmentGradeDomain {}
+export class FulfillmentGradeDomain {
+    _: (() => true) | undefined;
+    __typename = 'FulfillmentGradeDomain';
+}
 
 export class FulfillmentField {
     constructor(readonly fulfilled: boolean | null) {}
+    __typename = 'FulfillmentField';
 }
 
 export interface FulfillmentModelRecord {
@@ -44,13 +50,3 @@ export interface FulfillmentModelRecord {
     FulfillmentGradeDomain: FulfillmentGradeDomain;
     FulfillmentField: FulfillmentField;
 }
-
-export const fulfillmentResolvers: Resolvers = {
-    FulfillmentGradeDomain: {
-        _: () => true,
-    },
-    FulfillmentField: {
-        fulfilled: f => f.fulfilled,
-        valence: ({ fulfilled }): Valence | null => (fulfilled === null ? null : fulfilled ? 'SUCCESS' : 'FAILURE'),
-    },
-};
diff --git a/server/src/core/feedback/grade.ts b/server/src/core/feedback/grade.ts
index fdfe5995b..46a8f8d61 100644
--- a/server/src/core/feedback/grade.ts
+++ b/server/src/core/feedback/grade.ts
@@ -1,7 +1,4 @@
 import { gql } from 'apollo-server-core';
-import { Resolvers } from '../../main/resolver-types';
-import { FulfillmentField, FulfillmentGrade, FulfillmentGradeDomain } from './fulfillment';
-import { ScoreField, ScoreGrade, ScoreGradeDomain } from './score';
 
 export const gradeSchema = gql`
     """
@@ -17,27 +14,3 @@ export const gradeSchema = gql`
     "Field containing a grade."
     union GradeField = ScoreField | FulfillmentField
 `;
-
-export const gradeResolvers: Resolvers = {
-    Grade: {
-        __resolveType: grade => {
-            if (grade instanceof ScoreGrade) return 'ScoreGrade';
-            if (grade instanceof FulfillmentGrade) return 'FulfillmentGrade';
-            throw new Error(`Unknown grade domain ${grade}`);
-        },
-    },
-    GradeDomain: {
-        __resolveType: domain => {
-            if (domain instanceof ScoreGradeDomain) return 'ScoreGradeDomain';
-            if (domain instanceof FulfillmentGradeDomain) return 'FulfillmentGradeDomain';
-            throw new Error(`Unknown grade domain ${domain}`);
-        },
-    },
-    GradeField: {
-        __resolveType: field => {
-            if (field instanceof ScoreField) return 'ScoreField';
-            if (field instanceof FulfillmentField) return 'FulfillmentField';
-            throw new Error(`Unknown grade variable ${field}`);
-        },
-    },
-};
diff --git a/server/src/core/feedback/score.ts b/server/src/core/feedback/score.ts
index 919130aab..b14f72832 100644
--- a/server/src/core/feedback/score.ts
+++ b/server/src/core/feedback/score.ts
@@ -44,6 +44,7 @@ export const scoreSchema = gql`
 
 export class ScoreGrade {
     constructor(readonly scoreRange: ScoreRange, readonly score: number) {}
+    __typename = 'ScoreGrade';
 
     static total(values: ScoreGrade[]) {
         return new ScoreGrade(
@@ -55,6 +56,7 @@ export class ScoreGrade {
 
 export class ScoreRange {
     constructor(readonly max: number, readonly decimalDigits: number, readonly allowPartial: boolean) {}
+    __typename = 'ScoreRange';
 
     static total(domains: ScoreRange[]) {
         return new ScoreRange(
@@ -67,10 +69,12 @@ export class ScoreRange {
 
 export class ScoreGradeDomain {
     constructor(readonly scoreRange: ScoreRange) {}
+    __typename = 'ScoreGradeDomain';
 }
 
 export class ScoreField {
     constructor(readonly scoreRange: ScoreRange, readonly score: number | null) {}
+    __typename = 'ScoreField';
 
     valence(): Valence | null {
         return this.score === null
diff --git a/server/src/core/files/file-content.ts b/server/src/core/files/file-content.ts
index a5a818147..3aed56e02 100644
--- a/server/src/core/files/file-content.ts
+++ b/server/src/core/files/file-content.ts
@@ -5,7 +5,6 @@ import { AllowNull, Column, PrimaryKey, Table } from 'sequelize-typescript';
 import * as ssri from 'ssri';
 import { ApiObject } from '../../main/api';
 import { BaseModel, createByIdDataLoader } from '../../main/base-model';
-import { Resolvers } from '../../main/resolver-types';
 
 export const fileContentSchema = gql`
     type FileContent {
@@ -33,6 +32,13 @@ export class FileContent extends BaseModel<FileContent> {
     @AllowNull(false)
     @Column
     content!: Buffer;
+
+    base64() {
+        return this.content.toString('base64');
+    }
+    utf8() {
+        return this.content.toString('utf8');
+    }
 }
 
 export interface FileContentModelRecord {
@@ -69,11 +75,3 @@ export class FileContentApi extends ApiObject {
         return fs.promises.writeFile(filePath, fileContent.content);
     }
 }
-
-export const fileContentResolvers: Resolvers = {
-    FileContent: {
-        id: c => c.id,
-        base64: c => c.content.toString('base64'),
-        utf8: c => c.content.toString('utf8'),
-    },
-};
diff --git a/server/src/core/index.ts b/server/src/core/index.ts
index e30a1dc10..1681cbfd2 100644
--- a/server/src/core/index.ts
+++ b/server/src/core/index.ts
@@ -2,37 +2,23 @@ import { gql } from 'apollo-server-core';
 import { Resolvers } from '../main/resolver-types';
 import { Achievement, AchievementModelRecord, achievementSchema } from './achievement';
 import { authSchema } from './auth';
-import { ContestData, ContestModelRecord, contestResolvers, contestSchema } from './contest';
-import {
-    ContestAwardAssignmentModelRecord,
-    contestAwardAssignmentResolvers,
-    contestAwardAssignmentSchema,
-} from './contest-award-assignment';
-import {
-    ContestProblemAssignmentModelRecord,
-    contestProblemAssignmentResolvers,
-    contestProblemAssignmentSchema,
-} from './contest-problem-assignment';
+import { ContestData, ContestModelRecord, contestSchema } from './contest';
+import { ContestAwardAssignmentModelRecord, contestAwardAssignmentSchema } from './contest-award-assignment';
+import { ContestProblemAssignmentModelRecord, contestProblemAssignmentSchema } from './contest-problem-assignment';
 import {
     ContestProblemAssignmentUserTacklingModelRecord,
-    contestProblemAssignmentUserTacklingResolvers,
     contestProblemAssignmentUserTacklingSchema,
 } from './contest-problem-assignment-user-tackling';
+import { ContestProblemSetModelRecord, contestProblemSetSchema } from './contest-problem-set';
 import {
-    ContestProblemSetModelRecord,
-    contestProblemSetResolvers,
-    contestProblemSetSchema,
-} from './contest-problem-set';
-import {
-    contestAssignmentUserTacklingResolvers,
     ContestProblemSetUserTacklingModelRecord,
     contestProblemSetUserTacklingSchema,
 } from './contest-problem-set-user-tackling';
 import { Evaluation, EvaluationModelRecord, evaluationSchema } from './evaluation';
 import { EvaluationEvent } from './evaluation-event';
 import { fieldSchema } from './feedback/field';
-import { FulfillmentModelRecord, fulfillmentResolvers, fulfillmentSchema } from './feedback/fulfillment';
-import { gradeResolvers, gradeSchema } from './feedback/grade';
+import { FulfillmentModelRecord, fulfillmentSchema } from './feedback/fulfillment';
+import { gradeSchema } from './feedback/grade';
 import { headerSchema } from './feedback/header';
 import { memoryUsageSchema } from './feedback/memory-usage';
 import { messageFieldSchema } from './feedback/message';
@@ -40,37 +26,31 @@ import { ScoreModelRecord, scoreSchema } from './feedback/score';
 import { timeUsageSchema } from './feedback/time-usage';
 import { valenceSchema } from './feedback/valence';
 import { Archive, ArchiveModelRecord, archiveResolvers, archiveSchema } from './files/archive';
-import { FileContent, FileContentModelRecord, fileContentResolvers, fileContentSchema } from './files/file-content';
-import { AwardModelRecord, awardResolvers, awardSchema } from './material/award';
-import { MediaModelRecord, mediaResolvers, mediaSchema } from './material/media';
+import { FileContent, FileContentModelRecord, fileContentSchema } from './files/file-content';
+import { AwardModelRecord, awardSchema } from './material/award';
+import { MediaModelRecord, mediaSchema } from './material/media';
 import { ProblemMaterialModelRecord, problemMaterialSchema } from './material/problem-material';
-import { TextModelRecord, textResolvers, textSchema } from './material/text';
+import { TextModelRecord, textSchema } from './material/text';
 import { Message, messageSchema } from './message';
 import { MutationModelRecord, mutationResolvers, mutationSchema } from './mutation';
 import { ParticipationModelRecord, participationSchema } from './participation';
-import { ProblemModelRecord, problemResolvers, problemSchema } from './problem';
+import { ProblemModelRecord, problemSchema } from './problem';
 import { QueryModelRecord, queryResolvers, querySchema } from './query';
-import { SubmissionData, SubmissionModelRecord, submissionResolvers, submissionSchema } from './submission';
+import { SubmissionData, SubmissionModelRecord, submissionSchema } from './submission';
 import { SubmissionFile, submissionFileSchema } from './submission-file';
-import { UserModelRecord, userResolvers, userSchema } from './user';
+import { UserModelRecord, userSchema } from './user';
 import { DateTimeModelRecord, dateTimeResolvers, dateTimeSchema } from './util/date-time';
 import {
     ContestAwardAssignmentViewModelRecord,
-    contestAwardAssignmentViewResolvers,
     contestAwardAssignmentViewSchema,
 } from './view/contest-award-assignment-view';
 import {
     ContestProblemAssignmentViewModelRecord,
-    contestProblemAssignmentViewResolvers,
     contestProblemAssignmentViewSchema,
 } from './view/contest-problem-assignment-view';
-import {
-    ContestProblemSetViewModelRecord,
-    contestProblemSetViewResolvers,
-    contestProblemSetViewSchema,
-} from './view/contest-problem-set-view';
-import { ContestViewModelRecord, contestViewResolvers, contestViewSchema } from './view/contest-view';
-import { MainViewModelRecord, mainViewResolvers, mainViewSchema } from './view/main-view';
+import { ContestProblemSetViewModelRecord, contestProblemSetViewSchema } from './view/contest-problem-set-view';
+import { ContestViewModelRecord, contestViewSchema } from './view/contest-view';
+import { MainViewModelRecord, mainViewSchema } from './view/main-view';
 
 /** Full GraphQL schema document. Obtained combining schema parts from each components. */
 export const schema = gql`
@@ -165,28 +145,8 @@ export type ModelRecord = unknown &
 
 /** All GraphQL resolvers. Obtained combining resolvers from each components. */
 export const resolvers: Resolvers = {
-    ...awardResolvers,
-    ...contestAssignmentUserTacklingResolvers,
-    ...contestAwardAssignmentResolvers,
-    ...contestAwardAssignmentViewResolvers,
-    ...contestProblemAssignmentResolvers,
-    ...contestProblemAssignmentUserTacklingResolvers,
-    ...contestProblemAssignmentViewResolvers,
-    ...contestProblemSetResolvers,
-    ...contestProblemSetViewResolvers,
-    ...contestResolvers,
-    ...contestViewResolvers,
     ...dateTimeResolvers,
-    ...fileContentResolvers,
-    ...fulfillmentResolvers,
-    ...gradeResolvers,
-    ...mainViewResolvers,
-    ...mediaResolvers,
     ...mutationResolvers,
-    ...problemResolvers,
     ...queryResolvers,
-    ...submissionResolvers,
-    ...textResolvers,
-    ...userResolvers,
     ...archiveResolvers,
 };
diff --git a/server/src/core/material/award.ts b/server/src/core/material/award.ts
index 97efc647b..e2fa7bb0f 100644
--- a/server/src/core/material/award.ts
+++ b/server/src/core/material/award.ts
@@ -1,8 +1,8 @@
 import { gql } from 'apollo-server-core';
-import { Resolvers } from '../../main/resolver-types';
 import { FulfillmentGradeDomain } from '../feedback/fulfillment';
 import { ScoreGradeDomain, ScoreRange } from '../feedback/score';
 import { ProblemMaterial } from './problem-material';
+import { Text } from './text';
 
 export const awardSchema = gql`
     """
@@ -30,24 +30,21 @@ export class Award {
     private readonly subtaskInfo = this.material.taskInfo.IOI.scoring.subtasks[this.index];
 
     name = `subtask.${this.index}`;
-    title = [{ value: `Subtask ${this.index}` }];
+    title = new Text([{ value: `Subtask ${this.index}` }]);
 
     gradeDomain =
         this.subtaskInfo.max_score > 0
             ? new ScoreGradeDomain(new ScoreRange(this.subtaskInfo.max_score, 0, true))
             : new FulfillmentGradeDomain();
+
+    id() {
+        return `${this.material.problem.contest.id}/${this.material.problem.name}/${this.index}`;
+    }
+    problem() {
+        return this.material.problem;
+    }
 }
 
 export interface AwardModelRecord {
     Award: Award;
 }
-
-export const awardResolvers: Resolvers = {
-    Award: {
-        id: a => `${a.material.problem.contest.id}/${a.material.problem.name}/${a.index}`,
-        problem: a => a.material.problem,
-        name: a => a.name,
-        gradeDomain: a => a.gradeDomain,
-        title: a => a.title,
-    },
-};
diff --git a/server/src/core/material/media.ts b/server/src/core/material/media.ts
index 4e70d8aa3..081b4d6a0 100644
--- a/server/src/core/material/media.ts
+++ b/server/src/core/material/media.ts
@@ -1,6 +1,5 @@
 import { gql } from 'apollo-server-core';
 import { ApiContext } from '../../main/api-context';
-import { Resolvers } from '../../main/resolver-types';
 import { FileContent } from '../files/file-content';
 
 export const mediaSchema = gql`
@@ -31,29 +30,29 @@ export const mediaSchema = gql`
     }
 `;
 
-export type Media = MediaVariant[];
+export class Media {
+    constructor(readonly variants: MediaFile[]) {}
 
-export interface MediaVariant {
-    language?: string;
-
-    name: string;
-    type: string;
-    content: (ctx: ApiContext) => Promise<FileContent>;
+    variant() {
+        return this.variants[0];
+    }
 }
 
 export interface MediaModelRecord {
     Media: Media;
-    MediaFile: MediaVariant;
+    MediaFile: MediaFile;
 }
 
-export const mediaResolvers: Resolvers = {
-    Media: {
-        variant: media => media[0],
-    },
-    MediaFile: {
-        url: async (f, {}, ctx) => `/files/${(await f.content(ctx)).id}/${f.name}`,
-        content: (f, {}, ctx) => f.content(ctx),
-        name: f => f.name,
-        type: f => f.type,
-    },
-};
+export class MediaFile {
+    constructor(
+        readonly name: string,
+        readonly language: string | null,
+        readonly type: string,
+        readonly content: Promise<FileContent>,
+        readonly ctx: ApiContext,
+    ) {}
+
+    async url() {
+        return `/files/${(await this.content).id}/${this.name}`;
+    }
+}
diff --git a/server/src/core/material/problem-material.ts b/server/src/core/material/problem-material.ts
index 1d7ba2006..8f9470995 100644
--- a/server/src/core/material/problem-material.ts
+++ b/server/src/core/material/problem-material.ts
@@ -1,15 +1,16 @@
 import { gql } from 'apollo-server-core';
 import { __generated_Field } from '../../generated/graphql-types';
 import { ApiObject } from '../../main/api';
+import { ApiContext } from '../../main/api-context';
 import { createSimpleLoader } from '../../main/base-model';
-import { ContestApi } from '../contest';
+import { Contest, ContestApi } from '../contest';
 import { FulfillmentGradeDomain } from '../feedback/fulfillment';
 import { ScoreGradeDomain, ScoreRange } from '../feedback/score';
 import { Archive } from '../files/archive';
 import { FileContent } from '../files/file-content';
 import { Problem } from '../problem';
 import { Award } from './award';
-import { Media, MediaVariant } from './media';
+import { Media, MediaFile } from './media';
 import { ProblemTaskInfo, ProblemTaskInfoApi } from './problem-task-info';
 import { Text } from './text';
 
@@ -104,27 +105,27 @@ export interface ProblemAttribute {
 const languages = {
     python2: {
         name: 'python2',
-        title: [{ value: 'Python 2 (cpython)' }],
+        title: new Text([{ value: 'Python 2 (cpython)' }]),
     },
     python3: {
         name: 'py',
-        title: [{ value: 'Python 3 (cpython)' }],
+        title: new Text([{ value: 'Python 3 (cpython)' }]),
     },
     c: {
         name: 'c',
-        title: [{ value: 'C (c11)' }],
+        title: new Text([{ value: 'C (c11)' }]),
     },
     cpp: {
         name: 'cpp',
-        title: [{ value: 'C++ (c++17)' }],
+        title: new Text([{ value: 'C++ (c++17)' }]),
     },
     rust: {
         name: 'rust',
-        title: [{ value: 'Rust' }],
+        title: new Text([{ value: 'Rust' }]),
     },
     java: {
         name: 'java',
-        title: [{ value: 'Java 8 (JDK)' }],
+        title: new Text([{ value: 'Java 8 (JDK)' }]),
     },
 };
 
@@ -164,28 +165,27 @@ const fileRules = [
 const memoryUnitBytes = 1024 * 1024;
 
 export class ProblemMaterial {
-    constructor(readonly problem: Problem, readonly taskInfo: ProblemTaskInfo) {}
+    constructor(readonly problem: Problem, readonly taskInfo: ProblemTaskInfo, readonly ctx: ApiContext) {}
 
-    title = [{ value: this.taskInfo.IOI.title }];
-    statement = this.taskInfo.IOI.statements.map(
-        ({ path, language, content_type: type }): MediaVariant => ({
-            name: path.slice(path.lastIndexOf('/') + 1),
-            language,
-            type,
-            content: ctx => ctx.api(ProblemMaterialApi).loadContent(this.problem, path),
-        }),
+    title = new Text([{ value: this.taskInfo.IOI.title }]);
+
+    statement = new Media(
+        this.taskInfo.IOI.statements.map(
+            ({ path, language, content_type: type }): MediaFile =>
+                new MediaFile(
+                    path.slice(path.lastIndexOf('/') + 1),
+                    language,
+                    type,
+                    this.loadContent(this.problem, path),
+                    this.ctx,
+                ),
+        ),
     );
 
     attachments = this.taskInfo.IOI.attachments.map(
         ({ name, path, content_type: type }): ProblemAttachment => ({
-            title: [{ value: name }],
-            media: [
-                {
-                    name,
-                    type,
-                    content: ctx => ctx.api(ProblemMaterialApi).loadContent(this.problem, path),
-                },
-            ],
+            title: new Text([{ value: name }]),
+            media: new Media([new MediaFile(name, null, type, this.loadContent(this.problem, path), this.ctx)]),
         }),
     );
 
@@ -194,7 +194,7 @@ export class ProblemMaterial {
 
     attributes: ProblemAttribute[] = [
         {
-            title: [{ value: 'Time limit' }],
+            title: new Text([{ value: 'Time limit' }]),
             field: {
                 __typename: 'TimeUsageField',
                 timeUsageMaxRelevant: {
@@ -209,7 +209,7 @@ export class ProblemMaterial {
             icon: 'stopwatch',
         },
         {
-            title: [{ value: 'Memory limit' }],
+            title: new Text([{ value: 'Memory limit' }]),
             field: {
                 __typename: 'MemoryUsageField',
                 memoryUsageMaxRelevant: {
@@ -227,7 +227,7 @@ export class ProblemMaterial {
 
     awards = this.taskInfo.IOI.scoring.subtasks.map((subtask, index): Award => new Award(this, index));
 
-    submissionFields = [{ name: 'solution', title: [{ value: 'Solution' }] }];
+    submissionFields = [{ name: 'solution', title: new Text([{ value: 'Solution' }]) }];
     submissionFileTypes = Object.values(languages);
     submissionFileTypeRules = fileRules;
 
@@ -246,51 +246,40 @@ export class ProblemMaterial {
         }),
         {
             __typename: 'ScoreColumn',
-            title: [{ value: 'Total score' }],
+            title: new Text([{ value: 'Total score' }]),
         },
     ];
 
     evaluationFeedbackColumns = [
         {
             __typename: 'HeaderColumn',
-            title: [{ value: 'Subtask' }],
+            title: new Text([{ value: 'Subtask' }]),
         },
         {
             __typename: 'HeaderColumn',
-            title: [{ value: 'Case' }],
+            title: new Text([{ value: 'Case' }]),
         },
         {
             __typename: 'TimeUsageColumn',
-            title: [{ value: 'Time usage' }],
+            title: new Text([{ value: 'Time usage' }]),
         },
         {
             __typename: 'MemoryUsageColumn',
-            title: [{ value: 'Memory usage' }],
+            title: new Text([{ value: 'Memory usage' }]),
         },
         {
             __typename: 'MessageColumn',
-            title: [{ value: 'Message' }],
+            title: new Text([{ value: 'Message' }]),
         },
         {
             __typename: 'ScoreColumn',
-            title: [{ value: 'Score' }],
+            title: new Text([{ value: 'Score' }]),
         },
     ];
-}
-
-export interface ProblemMaterialModelRecord {
-    ProblemAttachment: ProblemAttachment;
-}
-
-export class ProblemMaterialApi extends ApiObject {
-    dataLoader = createSimpleLoader(
-        async (problem: Problem) =>
-            new ProblemMaterial(problem, await this.ctx.api(ProblemTaskInfoApi).getProblemTaskInfo(problem)),
-    );
 
     async loadContent(problem: Problem, path: string) {
-        const contest = this.ctx.api(ContestApi).fromId(problem.contest.id);
-        const { archiveId } = await this.ctx.api(ContestApi).dataLoader.load(contest);
+        const contest = new Contest(problem.contest.id, this.ctx);
+        const { archiveId } = await this.ctx.api(ContestApi).dataLoader.load(contest.id);
         const file = await this.ctx.table(Archive).findOne({
             where: { uuid: archiveId, path: `${problem.name}/${path}` },
             include: [this.ctx.table(FileContent)],
@@ -303,3 +292,18 @@ export class ProblemMaterialApi extends ApiObject {
         return file.content;
     }
 }
+
+export interface ProblemMaterialModelRecord {
+    ProblemAttachment: ProblemAttachment;
+}
+
+export class ProblemMaterialApi extends ApiObject {
+    dataLoader = createSimpleLoader(
+        async (id: string) =>
+            new ProblemMaterial(
+                Problem.fromId(id, this.ctx),
+                await this.ctx.api(ProblemTaskInfoApi).getProblemTaskInfo(Problem.fromId(id, this.ctx)),
+                this.ctx,
+            ),
+    );
+}
diff --git a/server/src/core/material/problem-task-info.ts b/server/src/core/material/problem-task-info.ts
index 3978f2acb..de4a69c42 100644
--- a/server/src/core/material/problem-task-info.ts
+++ b/server/src/core/material/problem-task-info.ts
@@ -38,7 +38,7 @@ export interface IOITaskInfo {
 
 export class ProblemTaskInfoApi extends ApiObject {
     async getProblemTaskInfo(problem: Problem): Promise<ProblemTaskInfo> {
-        const { archiveId } = await this.ctx.api(ContestApi).dataLoader.load(problem.contest);
+        const { archiveId } = await this.ctx.api(ContestApi).dataLoader.load(problem.contest.id);
         const metadataPath = `${problem.name}/.task-info.json`;
 
         const metadataProblemFile = await this.ctx.table(Archive).findOne({
diff --git a/server/src/core/material/text.ts b/server/src/core/material/text.ts
index d4fa17058..b157fb4ac 100644
--- a/server/src/core/material/text.ts
+++ b/server/src/core/material/text.ts
@@ -1,5 +1,4 @@
 import { gql } from 'apollo-server-core';
-import { Resolvers } from '../../main/resolver-types';
 
 export const textSchema = gql`
     """
@@ -13,7 +12,13 @@ export const textSchema = gql`
     }
 `;
 
-export type Text = TextVariant[];
+export class Text {
+    constructor(readonly variants: TextVariant[]) {}
+
+    variant() {
+        return this.variants[0].value;
+    }
+}
 
 export interface TextVariant {
     language?: string;
@@ -23,9 +28,3 @@ export interface TextVariant {
 export interface TextModelRecord {
     Text: Text;
 }
-
-export const textResolvers: Resolvers = {
-    Text: {
-        variant: text => text[0].value,
-    },
-};
diff --git a/server/src/core/mutation.ts b/server/src/core/mutation.ts
index e5d8ef04f..df6be0b43 100644
--- a/server/src/core/mutation.ts
+++ b/server/src/core/mutation.ts
@@ -1,7 +1,7 @@
 import { gql } from 'apollo-server-core';
 import { Resolvers } from '../main/resolver-types';
 import { MessageApi } from './message';
-import { SubmitApi } from './submit';
+import { Submit } from './submit';
 
 export const mutationSchema = gql`
     type Mutation {
@@ -26,7 +26,7 @@ export const mutationResolvers: Resolvers = {
         submit: async ({}, { submission }, ctx) => {
             await ctx.authorizeUser(submission.username);
 
-            return ctx.api(SubmitApi).submit(submission);
+            return Submit.submit(submission, ctx);
         },
         logIn: ({}, { token }, ctx) => ctx.environment.authService.logIn(token),
         sendMessage: async ({}, { message }, ctx) => {
diff --git a/server/src/core/problem.ts b/server/src/core/problem.ts
index fb3766b29..af4ddc9f0 100644
--- a/server/src/core/problem.ts
+++ b/server/src/core/problem.ts
@@ -1,5 +1,5 @@
 import { gql } from 'apollo-server-core';
-import { Resolvers } from '../main/resolver-types';
+import { ApiContext } from '../main/api-context';
 import { Contest } from './contest';
 import { ScoreGradeDomain } from './feedback/score';
 import { ProblemMaterialApi } from './material/problem-material';
@@ -16,36 +16,54 @@ export const problemSchema = gql`
     }
 `;
 
-export interface Problem {
-    __typename: 'Problem';
-    contest: Contest;
-    name: string;
+export class Problem {
+    constructor(readonly contest: Contest, readonly name: string, readonly ctx: ApiContext) {}
+    __typename = 'Problem';
+
+    static fromId(id: string, ctx: ApiContext): Problem {
+        const [contestId, problemName] = id.split('/');
+
+        return new Problem(new Contest(contestId, ctx), problemName, ctx);
+    }
+
+    id() {
+        return `${this.contest.id}/${this.name}`;
+    }
+    async title() {
+        return (await this.ctx.api(ProblemMaterialApi).dataLoader.load(this.id())).title;
+    }
+    async statement() {
+        return (await this.ctx.api(ProblemMaterialApi).dataLoader.load(this.id())).statement;
+    }
+    async attachments() {
+        return (await this.ctx.api(ProblemMaterialApi).dataLoader.load(this.id())).attachments;
+    }
+    async attributes() {
+        return (await this.ctx.api(ProblemMaterialApi).dataLoader.load(this.id())).attributes;
+    }
+    async awards() {
+        return (await this.ctx.api(ProblemMaterialApi).dataLoader.load(this.id())).awards;
+    }
+    async submissionFields() {
+        return (await this.ctx.api(ProblemMaterialApi).dataLoader.load(this.id())).submissionFields;
+    }
+    async submissionFileTypes() {
+        return (await this.ctx.api(ProblemMaterialApi).dataLoader.load(this.id())).submissionFileTypes;
+    }
+    async submissionFileTypeRules() {
+        return (await this.ctx.api(ProblemMaterialApi).dataLoader.load(this.id())).submissionFileTypeRules;
+    }
+    async submissionListColumns() {
+        return (await this.ctx.api(ProblemMaterialApi).dataLoader.load(this.id())).submissionListColumns;
+    }
+    async evaluationFeedbackColumns() {
+        return (await this.ctx.api(ProblemMaterialApi).dataLoader.load(this.id())).evaluationFeedbackColumns;
+    }
+    async totalScoreDomain() {
+        return new ScoreGradeDomain((await this.ctx.api(ProblemMaterialApi).dataLoader.load(this.id())).scoreRange);
+    }
 }
 
 export interface ProblemModelRecord {
     Problem: Problem;
 }
-
-export const problemResolvers: Resolvers = {
-    Problem: {
-        id: p => `${p.contest.id}/${p.name}`,
-        name: p => p.name,
-
-        title: async (p, {}, ctx) => (await ctx.api(ProblemMaterialApi).dataLoader.load(p)).title,
-        statement: async (p, {}, ctx) => (await ctx.api(ProblemMaterialApi).dataLoader.load(p)).statement,
-        attachments: async (p, {}, ctx) => (await ctx.api(ProblemMaterialApi).dataLoader.load(p)).attachments,
-        attributes: async (p, {}, ctx) => (await ctx.api(ProblemMaterialApi).dataLoader.load(p)).attributes,
-        awards: async (p, {}, ctx) => (await ctx.api(ProblemMaterialApi).dataLoader.load(p)).awards,
-        submissionFields: async (p, {}, ctx) => (await ctx.api(ProblemMaterialApi).dataLoader.load(p)).submissionFields,
-        submissionFileTypes: async (p, {}, ctx) =>
-            (await ctx.api(ProblemMaterialApi).dataLoader.load(p)).submissionFileTypes,
-        submissionFileTypeRules: async (p, {}, ctx) =>
-            (await ctx.api(ProblemMaterialApi).dataLoader.load(p)).submissionFileTypeRules,
-        submissionListColumns: async (p, {}, ctx) =>
-            (await ctx.api(ProblemMaterialApi).dataLoader.load(p)).submissionListColumns,
-        evaluationFeedbackColumns: async (p, {}, ctx) =>
-            (await ctx.api(ProblemMaterialApi).dataLoader.load(p)).evaluationFeedbackColumns,
-        totalScoreDomain: async (p, {}, ctx) =>
-            new ScoreGradeDomain((await ctx.api(ProblemMaterialApi).dataLoader.load(p)).scoreRange),
-    },
-};
diff --git a/server/src/core/query.ts b/server/src/core/query.ts
index 6e6b6ba1d..a2b144058 100644
--- a/server/src/core/query.ts
+++ b/server/src/core/query.ts
@@ -1,9 +1,9 @@
 import { gql } from 'apollo-server-core';
 import { Resolvers } from '../main/resolver-types';
-import { ContestApi, ContestData } from './contest';
+import { Contest, ContestData } from './contest';
 import { MessageApi } from './message';
-import { SubmissionApi} from './submission';
-import { UserApi } from './user';
+import { Submission } from './submission';
+import { User } from './user';
 import { MainView } from './view/main-view';
 export const querySchema = gql`
     type Query {
@@ -31,34 +31,33 @@ export const queryResolvers: Resolvers = {
                 await ctx.authorizeUser(username);
             }
 
-            const contest = await ctx.api(ContestApi).getDefault();
+            const contest = await Contest.getDefault(ctx);
 
             if (contest === null) throw new Error(`missing 'default' contest (not supported right now)`);
 
             return new MainView(
                 contest,
-                username !== null && username !== undefined
-                    ? await ctx.api(UserApi).validate({ __typename: 'User', contest, username })
-                    : null,
+                username !== null && username !== undefined ? await new User(contest, username, ctx).validate() : null,
+                ctx,
             );
         },
         contests: async ({}, {}, ctx) => {
             await ctx.authorizeAdmin();
 
-            return (await ctx.table(ContestData).findAll()).map(d => ctx.api(ContestApi).fromData(d))
+            return (await ctx.table(ContestData).findAll()).map(d => d.getContest(ctx));
         },
         archive: async (_, { uuid }, ctx) => {
             await ctx.authorizeAdmin();
 
-            return ({ uuid })
+            return { uuid };
         },
         submission: async ({}, { id }, ctx) => {
-            const sub = await ctx.api(SubmissionApi).validate({ __typename: 'Submission', id });
+            const sub = await new Submission(id, ctx).validate();
 
-            // Get the username of who had made the submission and verify if 
+            // Get the username of who had made the submission and verify if
             // the current user has teh permission to made the query.
             // The query is valid if is made by the owner or by an admin user.
-            const username = (await ctx.api(SubmissionApi).getTackling(sub)).user.username;
+            const username = (await sub.getTackling()).user.username;
             await ctx.authorizeUser(username);
 
             return sub;
diff --git a/server/src/core/submission-file.ts b/server/src/core/submission-file.ts
index dc4de132c..a07e97af3 100644
--- a/server/src/core/submission-file.ts
+++ b/server/src/core/submission-file.ts
@@ -49,7 +49,7 @@ export class SubmissionFile extends BaseModel<SubmissionFile> {
     getContent!: (options?: FindOptions) => Promise<FileContent>;
 }
 
-export class SubmissionFileApi extends ApiObject {
+export class SubmissionFileCache extends ApiObject {
     allBySubmissionId = createSimpleLoader((submissionId: string) =>
         this.ctx.table(SubmissionFile).findAll({ where: { submissionId } }),
     );
diff --git a/server/src/core/submission.ts b/server/src/core/submission.ts
index a2c17932c..dcfa73ed4 100644
--- a/server/src/core/submission.ts
+++ b/server/src/core/submission.ts
@@ -3,20 +3,24 @@ import * as path from 'path';
 import { AllowNull, Column, ForeignKey, Table } from 'sequelize-typescript';
 import { __generated_SubmissionInput } from '../generated/graphql-types';
 import { ApiObject } from '../main/api';
+import { ApiContext } from '../main/api-context';
 import { createSimpleLoader, UuidBaseModel } from '../main/base-model';
-import { Resolvers } from '../main/resolver-types';
 import { typed } from '../util/types';
-import { AchievementApi } from './achievement';
-import { ContestApi, ContestData } from './contest';
+import { AchievementCache } from './achievement';
+import { Contest, ContestData } from './contest';
+import { ContestProblemAssignment } from './contest-problem-assignment';
 import { ContestProblemAssignmentUserTackling } from './contest-problem-assignment-user-tackling';
-import { Evaluation, EvaluationApi, EvaluationStatus } from './evaluation';
-import { EvaluationEventApi } from './evaluation-event';
+import { Evaluation, EvaluationCache, EvaluationStatus } from './evaluation';
+import { EvaluationEventCache } from './evaluation-event';
 import { FulfillmentGradeDomain } from './feedback/fulfillment';
-import { ScoreGrade, ScoreGradeDomain } from './feedback/score';
+import { ScoreField, ScoreGrade, ScoreGradeDomain, ScoreRange } from './feedback/score';
 import { FileContentApi } from './files/file-content';
 import { ProblemMaterialApi } from './material/problem-material';
+import { Text } from './material/text';
 import { Participation } from './participation';
-import { SubmissionFileApi } from './submission-file';
+import { Problem } from './problem';
+import { SubmissionFileCache } from './submission-file';
+import { User } from './user';
 
 export const submissionSchema = gql`
     type Submission {
@@ -62,78 +66,109 @@ export class SubmissionData extends UuidBaseModel<SubmissionData> {
     username!: string;
 }
 
-export interface Submission {
-    __typename: 'Submission';
-    id: string;
-}
+export class Submission {
+    constructor(readonly id: string, readonly ctx: ApiContext) {}
+    __typename = 'Submission';
 
-export interface SubmissionModelRecord {
-    Submission: Submission;
-}
+    async contest() {
+        return (await this.getTackling()).assignment.problem.contest;
+    }
 
-export type SubmissionInput = __generated_SubmissionInput;
+    async user() {
+        return (await this.getTackling()).user;
+    }
 
-export class SubmissionApi extends ApiObject {
-    fromId(id: string) {
-        return typed<Submission>({ __typename: 'Submission', id });
+    async problem() {
+        return (await this.getTackling()).assignment.problem;
     }
 
-    dataLoader = createSimpleLoader((s: Submission) => this.ctx.table(SubmissionData).findByPk(s.id));
+    async participation() {
+        const tackling = await this.getTackling();
 
-    async validate(submission: Submission) {
-        await this.dataLoader.load(submission);
+        return typed<Participation>({
+            __typename: 'Participation',
+            contest: tackling.assignment.problem.contest,
+            user: tackling.user,
+        });
+    }
 
-        return submission;
+    async contestProblemAssigment() {
+        return (await this.getTackling()).assignment;
     }
 
-    allByTackling = createSimpleLoader(
-        async ({
-            assignment: {
-                problem: {
-                    contest: { id: contestId },
-                    name: problemName,
-                },
-            },
-            user: { username },
-        }: ContestProblemAssignmentUserTackling) =>
-            (
-                await this.ctx
-                    .table(SubmissionData)
-                    .findAll({ where: { problemName, contestId, username }, order: [['createdAt', 'DESC']] })
-            ).map(data => this.fromId(data.id)),
-    );
+    async officialEvaluation() {
+        return this.getOfficialEvaluation();
+    }
 
-    pendingByContestAndUser = createSimpleLoader(
-        async ({ contestId, username }: { contestId: string; username: string }) => {
-            // TODO: denormalize DB to make this code simpler and faster
-            const submissions = await this.ctx.table(SubmissionData).findAll({ where: { username } });
-            const officalEvaluations = await Promise.all(
-                submissions.map(async s => this.getOfficialEvaluation(this.fromId(s.id))),
-            );
+    async summaryRow() {
+        const scoreRange = (await this.getMaterial()).scoreRange;
+        const score = (await this.getTotalScore())?.score;
 
-            return submissions
-                .filter((s, i) => officalEvaluations[i]?.status === 'PENDING')
-                .map(s => this.fromId(s.id));
-        },
-    );
+        return {
+            __typename: 'Record',
+            valence:
+                score !== undefined ? (score >= scoreRange.max ? 'SUCCESS' : score > 0 ? 'PARTIAL' : 'FAILURE') : null,
+            fields: [
+                ...(await this.getAwardAchievements()).map(({ award: { gradeDomain }, achievement }) => {
+                    if (gradeDomain instanceof ScoreGradeDomain) {
+                        return new ScoreField(
+                            gradeDomain.scoreRange,
+                            achievement !== undefined ? achievement.getScoreGrade(gradeDomain).score : null,
+                        );
+                    }
+                    if (gradeDomain instanceof FulfillmentGradeDomain) {
+                        return {
+                            __typename: 'FulfillmentField',
+                            fulfilled: achievement !== undefined ? achievement.getFulfillmentGrade().fulfilled : null,
+                        };
+                    }
+                    throw new Error(`unexpected grade domain ${gradeDomain}`);
+                }),
+                new ScoreField(scoreRange, score !== undefined ? score : null),
+            ],
+        };
+    }
 
-    async getSubmissionFiles(s: Submission) {
-        return this.ctx.api(SubmissionFileApi).allBySubmissionId.load(s.id);
+    async feedbackTable() {
+        return this.getFeedbackTable();
     }
 
-    async getTackling(s: Submission) {
-        const { contestId, problemName, username } = await this.dataLoader.load(s);
+    async createdAt() {
+        return (await this.ctx.api(SubmissionCache).dataLoader.load(this.id)).createdAt;
+    }
 
-        const contest = this.ctx.api(ContestApi).fromId(contestId);
+    async evaluations() {
+        return this.ctx.api(EvaluationCache).allBySubmissionId.load(this.id);
+    }
 
-        return typed<ContestProblemAssignmentUserTackling>({
-            __typename: 'ContestProblemAssignmentUserTackling',
-            assignment: {
-                __typename: 'ContestProblemAssignment',
-                problem: { __typename: 'Problem', contest, name: problemName },
-            },
-            user: { __typename: 'User', contest, username },
-        });
+    async files() {
+        return this.getSubmissionFiles();
+    }
+
+    static fromId(id: string, ctx: ApiContext): Submission {
+        return new Submission(id, ctx);
+    }
+
+    async validate() {
+        await this.ctx.api(SubmissionCache).dataLoader.load(this.id);
+
+        return this;
+    }
+
+    async getSubmissionFiles() {
+        return this.ctx.api(SubmissionFileCache).allBySubmissionId.load(this.id);
+    }
+
+    async getTackling() {
+        const { contestId, problemName, username } = await this.ctx.api(SubmissionCache).dataLoader.load(this.id);
+
+        const contest = new Contest(contestId, this.ctx);
+
+        return new ContestProblemAssignmentUserTackling(
+            new ContestProblemAssignment(new Problem(contest, problemName, this.ctx)),
+            new User(contest, username, this.ctx),
+            this.ctx,
+        );
     }
 
     /**
@@ -142,9 +177,9 @@ export class SubmissionApi extends ApiObject {
      *
      * @param base base directory
      */
-    async extract(s: Submission, base: string) {
-        const submissionFiles = await this.getSubmissionFiles(s);
-        const submissionPath = path.join(base, s.id);
+    async extract(base: string) {
+        const submissionFiles = await this.getSubmissionFiles();
+        const submissionPath = path.join(base, this.id);
 
         for (const submissionFile of submissionFiles) {
             const content = await submissionFile.getContent({ attributes: ['id', 'content'] });
@@ -159,24 +194,24 @@ export class SubmissionApi extends ApiObject {
         return submissionPath;
     }
 
-    async getOfficialEvaluation(s: Submission) {
+    async getOfficialEvaluation() {
         return this.ctx.table(Evaluation).findOne({
-            where: { submissionId: s.id },
+            where: { submissionId: this.id },
             order: [['createdAt', 'DESC']],
         });
     }
 
-    async getMaterial(s: Submission) {
-        const { assignment } = await this.getTackling(s);
+    async getMaterial() {
+        const { assignment } = await this.getTackling();
 
-        return this.ctx.api(ProblemMaterialApi).dataLoader.load(assignment.problem);
+        return this.ctx.api(ProblemMaterialApi).dataLoader.load(assignment.problem.id());
     }
 
-    async getAwardAchievements(s: Submission) {
-        const evaluation = await this.getOfficialEvaluation(s);
+    async getAwardAchievements() {
+        const evaluation = await this.getOfficialEvaluation();
         const achievements =
-            evaluation !== null ? await this.ctx.api(AchievementApi).allByEvaluationId.load(evaluation.id) : [];
-        const material = await this.getMaterial(s);
+            evaluation !== null ? await this.ctx.api(AchievementCache).allByEvaluationId.load(evaluation.id) : [];
+        const material = await this.getMaterial();
 
         return material.awards.map((award, awardIndex) => ({
             award,
@@ -184,13 +219,13 @@ export class SubmissionApi extends ApiObject {
         }));
     }
 
-    async getTotalScore(s: Submission) {
-        if ((await this.getOfficialEvaluation(s))?.status !== EvaluationStatus.SUCCESS) return undefined;
+    async getTotalScore() {
+        if ((await this.getOfficialEvaluation())?.status !== EvaluationStatus.SUCCESS) return undefined;
 
         return ScoreGrade.total(
-            (await this.getAwardAchievements(s)).flatMap(({ achievement, award: { gradeDomain } }) => {
+            (await this.getAwardAchievements()).flatMap(({ achievement, award: { gradeDomain } }) => {
                 if (gradeDomain instanceof ScoreGradeDomain && achievement !== undefined) {
-                    return [this.ctx.api(AchievementApi).getScoreGrade(achievement, gradeDomain)];
+                    return [achievement.getScoreGrade(gradeDomain)];
                 }
 
                 return [];
@@ -198,63 +233,23 @@ export class SubmissionApi extends ApiObject {
         );
     }
 
-    async getSummaryRow(s: Submission) {
-        const scoreRange = (await this.getMaterial(s)).scoreRange;
-        const score = (await this.getTotalScore(s))?.score;
-
-        return {
-            __typename: 'Record',
-            valence:
-                score !== undefined ? (score >= scoreRange.max ? 'SUCCESS' : score > 0 ? 'PARTIAL' : 'FAILURE') : null,
-            fields: [
-                ...(await this.getAwardAchievements(s)).map(({ award: { gradeDomain }, achievement }) => {
-                    if (gradeDomain instanceof ScoreGradeDomain) {
-                        return {
-                            __typename: 'ScoreField',
-                            score:
-                                achievement !== undefined
-                                    ? this.ctx.api(AchievementApi).getScoreGrade(achievement, gradeDomain).score
-                                    : null,
-                            scoreRange: gradeDomain.scoreRange,
-                        };
-                    }
-                    if (gradeDomain instanceof FulfillmentGradeDomain) {
-                        return {
-                            __typename: 'FulfillmentField',
-                            fulfilled:
-                                achievement !== undefined
-                                    ? this.ctx.api(AchievementApi).getFulfillmentGrade(achievement).fulfilled
-                                    : null,
-                        };
-                    }
-                    throw new Error(`unexpected grade domain ${gradeDomain}`);
-                }),
-                {
-                    __typename: 'ScoreField',
-                    score,
-                    scoreRange,
-                },
-            ],
-        };
-    }
-
-    async getFeedbackTable(s: Submission) {
+    async getFeedbackTable() {
         const {
             awards,
             taskInfo,
             evaluationFeedbackColumns,
             timeLimitSeconds,
             memoryLimitBytes,
-        } = await this.getMaterial(s);
+        } = await this.getMaterial();
         const { scoring } = taskInfo.IOI;
 
         const limitsMarginMultiplier = 2;
         const memoryUnitBytes = 1024;
         const warningWatermarkMultiplier = 0.2;
 
-        const evaluation = await this.getOfficialEvaluation(s);
+        const evaluation = await this.getOfficialEvaluation();
         const events =
-            evaluation !== null ? await this.ctx.api(EvaluationEventApi).allByEvaluationId.load(evaluation.id) : [];
+            evaluation !== null ? await this.ctx.api(EvaluationEventCache).allByEvaluationId.load(evaluation.id) : [];
 
         const testCasesData = awards.flatMap((award, awardIndex) =>
             new Array(scoring.subtasks[awardIndex].testcases).fill(0).map(() => ({
@@ -295,12 +290,12 @@ export class SubmissionApi extends ApiObject {
                     {
                         __typename: 'HeaderField',
                         index: awardIndex,
-                        title: [{ value: `Subtask ${awardIndex}` }],
+                        title: new Text([{ value: `Subtask ${awardIndex}` }]),
                     },
                     {
                         __typename: 'HeaderField',
                         index: testCaseIndex,
-                        title: [{ value: `Case ${testCaseIndex}` }],
+                        title: new Text([{ value: `Case ${testCaseIndex}` }]),
                     },
                     {
                         __typename: 'TimeUsageField',
@@ -338,45 +333,46 @@ export class SubmissionApi extends ApiObject {
                         __typename: 'MessageField',
                         message: message !== null ? [{ value: message }] : null,
                     },
-                    {
-                        __typename: 'ScoreField',
-                        score,
-                        scoreRange: {
-                            max: 1,
-                            decimalDigits: 2,
-                            allowPartial: true,
-                        },
-                    },
+                    new ScoreField(new ScoreRange(1, 2, true), score),
                 ],
             })),
         };
     }
 }
 
-export const submissionResolvers: Resolvers = {
-    Submission: {
-        id: s => s.id,
-
-        contest: async (s, {}, ctx) => (await ctx.api(SubmissionApi).getTackling(s)).assignment.problem.contest,
-        user: async (s, {}, ctx) => (await ctx.api(SubmissionApi).getTackling(s)).user,
-        problem: async (s, {}, ctx) => (await ctx.api(SubmissionApi).getTackling(s)).assignment.problem,
-        participation: async (s, {}, ctx) => {
-            const tackling = await ctx.api(SubmissionApi).getTackling(s);
-
-            return typed<Participation>({
-                __typename: 'Participation',
-                contest: tackling.assignment.problem.contest,
-                user: tackling.user,
-            });
-        },
-        contestProblemAssigment: async (s, {}, ctx) => (await ctx.api(SubmissionApi).getTackling(s)).assignment,
+export interface SubmissionModelRecord {
+    Submission: Submission;
+}
+
+export type SubmissionInput = __generated_SubmissionInput;
+
+export class SubmissionCache extends ApiObject {
+    dataLoader = createSimpleLoader((id: string) => this.ctx.table(SubmissionData).findByPk(id));
 
-        officialEvaluation: (s, {}, ctx) => ctx.api(SubmissionApi).getOfficialEvaluation(s),
-        summaryRow: (s, {}, ctx) => ctx.api(SubmissionApi).getSummaryRow(s),
-        feedbackTable: (s, {}, ctx) => ctx.api(SubmissionApi).getFeedbackTable(s),
+    allByTackling = createSimpleLoader(async (id: string) => {
+        const cpaut = ContestProblemAssignmentUserTackling.fromId(id, this.ctx);
+        const problemName = cpaut.assignment.problem.name;
+        const contestId = cpaut.assignment.contest().id;
+        const username = cpaut.user.username;
 
-        createdAt: async (s, {}, ctx) => (await ctx.api(SubmissionApi).dataLoader.load(s)).createdAt,
-        evaluations: (s, {}, ctx) => ctx.api(EvaluationApi).allBySubmissionId.load(s.id),
-        files: (s, {}, ctx) => ctx.api(SubmissionApi).getSubmissionFiles(s),
-    },
-};
+        return (
+            await this.ctx
+                .table(SubmissionData)
+                .findAll({ where: { problemName, contestId, username }, order: [['createdAt', 'DESC']] })
+        ).map(data => Submission.fromId(data.id, this.ctx));
+    });
+
+    pendingByContestAndUser = createSimpleLoader(
+        async ({ contestId, username }: { contestId: string; username: string }) => {
+            // TODO: denormalize DB to make this code simpler and faster
+            const submissions = await this.ctx.table(SubmissionData).findAll({ where: { username } });
+            const officialEvaluations = await Promise.all(
+                submissions.map(async s => Submission.fromId(s.id, this.ctx).getOfficialEvaluation()),
+            );
+
+            return submissions
+                .filter((s, i) => officialEvaluations[i]?.status === 'PENDING')
+                .map(s => Submission.fromId(s.id, this.ctx));
+        },
+    );
+}
diff --git a/server/src/core/submit.ts b/server/src/core/submit.ts
index 1e422a9c5..311e784eb 100644
--- a/server/src/core/submit.ts
+++ b/server/src/core/submit.ts
@@ -1,50 +1,47 @@
 import * as path from 'path';
-import { ApiObject } from '../main/api';
-import { ContestApi } from './contest';
+import { ApiContext } from '../main/api-context';
+import { Contest } from './contest';
 import { EvaluateApi } from './evaluate';
 import { FileContentApi } from './files/file-content';
-import { SubmissionApi, SubmissionData, SubmissionInput } from './submission';
+import { Submission, SubmissionData, SubmissionInput } from './submission';
 import { SubmissionFile } from './submission-file';
-import { UserApi } from './user';
+import { User } from './user';
 
-export class SubmitApi extends ApiObject {
-    async submit(
+export class Submit {
+    static async submit(
         { contestId, problemName, username, files }: SubmissionInput,
+        ctx: ApiContext,
         // Path of a local file to submit as solution (C++, C , Python only), used as a shortcut for the CLI
         // FIXME: improve the CLI to support multiple fields/file-types
         solutionPath?: string,
     ) {
-        const contest = await this.ctx.api(ContestApi).validate({ __typename: 'Contest', id: contestId });
-        await this.ctx.api(UserApi).validate({ __typename: 'User', contest, username });
+        const contest = await new Contest(contestId, ctx).validate();
+        await new User(contest, username, ctx).validate();
 
-        const submissionData = await this.ctx
-            .table(SubmissionData)
-            .create({ contestId: contest.id, problemName, username });
-        const submission = this.ctx.api(SubmissionApi).fromId(submissionData.id);
+        const submissionData = await ctx.table(SubmissionData).create({ contestId: contest.id, problemName, username });
+        const submission = Submission.fromId(submissionData.id, ctx);
 
         for (const { content, fieldName, fileName, fileTypeName } of files) {
-            await this.ctx.table(SubmissionFile).create({
+            await ctx.table(SubmissionFile).create({
                 fieldName,
                 fileName,
                 submissionId: submissionData.id,
                 fileTypeName,
-                contentId: (await this.ctx.api(FileContentApi).createFromContent(Buffer.from(content.base64, 'base64')))
-                    .id,
+                contentId: (await ctx.api(FileContentApi).createFromContent(Buffer.from(content.base64, 'base64'))).id,
             });
         }
 
         if (solutionPath !== undefined) {
-            await this.ctx.table(SubmissionFile).create({
+            await ctx.table(SubmissionFile).create({
                 submissionId: submissionData.id,
                 fieldName: 'solution',
                 fileTypeName: path.extname(solutionPath),
                 fileName: path.basename(solutionPath),
-                contentId: (await this.ctx.api(FileContentApi).createFromPath(solutionPath)).id,
+                contentId: (await ctx.api(FileContentApi).createFromPath(solutionPath)).id,
             });
         }
 
-        this.ctx
-            .api(EvaluateApi)
+        ctx.api(EvaluateApi)
             .evaluate(submission)
             .catch(e => {
                 console.error(`UNEXPECTED ERROR DURING EVALUATION:`);
diff --git a/server/src/core/user.ts b/server/src/core/user.ts
index cc8cfae0a..8d952bb9c 100644
--- a/server/src/core/user.ts
+++ b/server/src/core/user.ts
@@ -1,10 +1,8 @@
 import { gql } from 'apollo-server-core';
 import { ApiObject } from '../main/api';
+import { ApiContext } from '../main/api-context';
 import { createSimpleLoader } from '../main/base-model';
-import { Resolvers } from '../main/resolver-types';
-import { typed } from '../util/types';
-import { Contest, ContestApi } from './contest';
-
+import { Contest } from './contest';
 
 export const userSchema = gql`
     type User {
@@ -32,47 +30,41 @@ export enum UserRole {
     ADMIN,
 }
 
-export interface User {
-    __typename: 'User';
-    contest: Contest;
-    username: string;
-}
+export class User {
+    constructor(readonly contest: Contest, readonly username: string, readonly  ctx : ApiContext) {}
+    __typename = 'User';
+    id() {
+        return `${this.contest.id}/${this.username}`;
+    }
+    async name() {
+        return (await this.ctx.api(UserCache).metadataLoader.load(this.id())).name;
+    }
 
-export class UserApi extends ApiObject {
-    metadataLoader = createSimpleLoader(async ({ contest, username }: User) => {
-        const contestMetadata = await this.ctx.api(ContestApi).getMetadata(contest);
+    async validate() {
+        await this.ctx.api(UserCache).metadataLoader.load(this.id());
 
-        return (
-            contestMetadata.users.find(data => data.username === username) ??
-            this.ctx.fail(`user ${username} does not exist in contest ${contest.id}`)
-        );
-    });
+        return this;
+    }
 
-    async validate(user: User) {
-        await this.metadataLoader.load(user);
+    static fromId(id: string, ctx: ApiContext): User {
+        const [contestId, username] = id.split('/');
 
-        return user;
+        return new User(new Contest(contestId, ctx), username, ctx);
     }
+}
 
-    async getUserByToken(contest: Contest, token: string) {
-        const contestMetadata = await this.ctx.api(ContestApi).getMetadata(contest);
-        const userMetadata = contestMetadata.users.find(data => data.token === token) ?? null;
-
-        if (userMetadata === null) return null;
-        const { username } = userMetadata;
+export class UserCache extends ApiObject {
+    metadataLoader = createSimpleLoader(async (id: string) => {
+        const user = User.fromId(id, this.ctx);
+        const contestMetadata = await user.contest.getMetadata();
 
-        return typed<User>({ __typename: 'User', contest, username });
-    }
+        return (
+            contestMetadata.users.find(data => data.username === user.username) ??
+            this.ctx.fail(`user ${user.username} does not exist in contest ${user.contest.id}`)
+        );
+    });
 }
 
 export interface UserModelRecord {
     User: User;
 }
-
-export const userResolvers: Resolvers = {
-    User: {
-        id: ({ contest, username }) => `${contest.id}/${username}`,
-        username: u => u.username,
-        name: async (u, {}, ctx) => (await ctx.api(UserApi).metadataLoader.load(u)).name,
-    },
-};
diff --git a/server/src/core/view/main-view.ts b/server/src/core/view/main-view.ts
index 0fc1611ab..e278050ae 100644
--- a/server/src/core/view/main-view.ts
+++ b/server/src/core/view/main-view.ts
@@ -1,8 +1,10 @@
 import { gql } from 'apollo-server-core';
-import { Resolvers } from '../../main/resolver-types';
-import { Contest, ContestApi } from '../contest';
-import { SubmissionApi } from '../submission';
+import { ApiContext } from '../../main/api-context';
+import { Contest } from '../contest';
+import { Text } from '../material/text';
+import { SubmissionCache } from '../submission';
 import { User } from '../user';
+import { ContestView } from './contest-view';
 
 export const mainViewSchema = gql`
     """
@@ -27,26 +29,23 @@ export const mainViewSchema = gql`
 `;
 
 export class MainView {
-    constructor(readonly contest: Contest, readonly user: User | null) {}
+    constructor(readonly contest: Contest, readonly user: User | null, readonly ctx: ApiContext) {}
+    async title() {
+        return new Text([{ value: (await this.contest.getMetadata()).title ?? 'TuringArena' }]);
+    }
+    contestView() {
+        return new ContestView(this.contest, this.user, this.ctx);
+    }
+    async pendingSubmissions() {
+        return this.user !== null
+            ? this.ctx.api(SubmissionCache).pendingByContestAndUser.load({
+                  contestId: this.contest.id,
+                  username: this.user.username,
+              })
+            : [];
+    }
 }
 
 export interface MainViewModelRecord {
     MainView: MainView;
 }
-
-export const mainViewResolvers: Resolvers = {
-    MainView: {
-        user: ({ user }) => user,
-        title: async (v, {}, ctx) => [
-            { value: (await ctx.api(ContestApi).getMetadata(v.contest)).title ?? 'TuringArena' },
-        ],
-        contestView: ({ contest, user }) => ({ __typename: 'ContestView', contest, user }),
-        pendingSubmissions: async (v, {}, ctx) =>
-            v.user !== null
-                ? ctx.api(SubmissionApi).pendingByContestAndUser.load({
-                      contestId: v.contest.id,
-                      username: v.user.username,
-                  })
-                : [],
-    },
-};
diff --git a/server/src/main/api-context.ts b/server/src/main/api-context.ts
index e1b56151e..e082974cf 100644
--- a/server/src/main/api-context.ts
+++ b/server/src/main/api-context.ts
@@ -2,7 +2,7 @@ import { DocumentNode, execute } from 'graphql';
 import { Model, Sequelize } from 'sequelize-typescript';
 import { modelConstructors } from '../core';
 import { AuthService } from '../core/auth';
-import { User, UserApi } from '../core/user';
+import { User, UserCache } from '../core/user';
 import { ApiObject } from './api';
 import { Config, defaultConfig } from './config';
 import { createSchema } from './executable-schema';
@@ -90,7 +90,7 @@ export class RemoteApiContext extends ApiContext {
         if (this.environment.config.skipAuth) return true;
 
         if (this.user === undefined) return false;
-        const { role } = await this.api(UserApi).metadataLoader.load(this.user);
+        const { role } = await this.api(UserCache).metadataLoader.load(this.user.id());
 
         return role === 'admin';
     }


Test patch:
diff --git a/examples/example-contest/easy1/testo/testo.md b/examples/example-contest/easy1/testo/testo.md
index 651059a55..0af171f9d 100644
--- a/examples/example-contest/easy1/testo/testo.md
+++ b/examples/example-contest/easy1/testo/testo.md
@@ -22,6 +22,7 @@ Nel file **output.txt** dovrai stampare un singolo numero intero, il valore mass
 della sequenza.
 
 ### Assunzioni
+
 - $1 \le N \le 1000$.
 - $|S_i| < 1000$, ovvero $-1000 < S_i < 1000$.
 
diff --git a/server/src/core/contest-award-assignment-user-tackling.ts b/server/src/core/contest-award-assignment-user-tackling.ts
index 5dce112c5..ec8c8ed40 100644
--- a/server/src/core/contest-award-assignment-user-tackling.ts
+++ b/server/src/core/contest-award-assignment-user-tackling.ts
@@ -1,19 +1,17 @@
 import { QueryTypes } from 'sequelize';
-import { ApiObject } from '../main/api';
-import { Achievement, AchievementApi } from './achievement';
+import { ApiContext } from '../main/api-context';
+import { Achievement } from './achievement';
 import { ContestAwardAssignment } from './contest-award-assignment';
 import { FulfillmentGrade, FulfillmentGradeDomain } from './feedback/fulfillment';
 import { ScoreGrade, ScoreGradeDomain } from './feedback/score';
 import { User } from './user';
 
-export interface ContestAwardAssignmentUserTackling {
-    __typename: 'ContestAwardAssignmentUserTackling';
-    assignment: ContestAwardAssignment;
-    user: User;
-}
+export class ContestAwardAssignmentUserTackling {
+    constructor(readonly assignment: ContestAwardAssignment, readonly user: User, readonly ctx: ApiContext) {}
+
+    __typename = 'ContestAwardAssignmentUserTackling';
 
-export class ContestAwardAssignmentUserTacklingApi extends ApiObject {
-    async getBestAchievement(t: ContestAwardAssignmentUserTackling) {
+    async getBestAchievement() {
         const achievements = await this.ctx.environment.sequelize.query<Achievement>(
             `
                 WITH
@@ -62,10 +60,10 @@ export class ContestAwardAssignmentUserTacklingApi extends ApiObject {
             `,
             {
                 bind: {
-                    contestId: t.assignment.problemAssignment.problem.contest.id,
-                    problemName: t.assignment.problemAssignment.problem.name,
-                    username: t.user.username,
-                    awardIndex: t.assignment.award.index,
+                    contestId: this.assignment.problemAssignment.problem.contest.id,
+                    problemName: this.assignment.problemAssignment.problem.name,
+                    username: this.user.username,
+                    awardIndex: this.assignment.award.index,
                 },
                 type: QueryTypes.SELECT,
                 mapToModel: true,
@@ -76,26 +74,22 @@ export class ContestAwardAssignmentUserTacklingApi extends ApiObject {
         return achievements.length > 0 ? achievements[0] : null;
     }
 
-    async getScoreGrade(t: ContestAwardAssignmentUserTackling, domain: ScoreGradeDomain) {
-        const bestAchievement = await this.getBestAchievement(t);
+    async getScoreGrade(domain: ScoreGradeDomain) {
+        const bestAchievement = await this.getBestAchievement();
 
-        return bestAchievement !== null
-            ? this.ctx.api(AchievementApi).getScoreGrade(bestAchievement, domain)
-            : new ScoreGrade(domain.scoreRange, 0);
+        return bestAchievement !== null ? bestAchievement.getScoreGrade(domain) : new ScoreGrade(domain.scoreRange, 0);
     }
 
-    async getFulfillmentGrade(t: ContestAwardAssignmentUserTackling) {
-        const bestAchievement = await this.getBestAchievement(t);
+    async getFulfillmentGrade() {
+        const bestAchievement = await this.getBestAchievement();
 
-        return bestAchievement !== null
-            ? this.ctx.api(AchievementApi).getFulfillmentGrade(bestAchievement)
-            : new FulfillmentGrade(false);
+        return bestAchievement !== null ? bestAchievement.getFulfillmentGrade() : new FulfillmentGrade(false);
     }
 
-    async getGrade(t: ContestAwardAssignmentUserTackling) {
-        const { gradeDomain: domain } = t.assignment.award;
-        if (domain instanceof FulfillmentGradeDomain) return (await this.getFulfillmentGrade(t)) ?? null;
-        if (domain instanceof ScoreGradeDomain) return (await this.getScoreGrade(t, domain)) ?? null;
+    async getGrade() {
+        const { gradeDomain: domain } = this.assignment.award;
+        if (domain instanceof FulfillmentGradeDomain) return (await this.getFulfillmentGrade()) ?? null;
+        if (domain instanceof ScoreGradeDomain) return (await this.getScoreGrade(domain)) ?? null;
         throw new Error(`unexpected grade domain ${domain}`);
     }
 }
diff --git a/server/src/core/contest-award-assignment.ts b/server/src/core/contest-award-assignment.ts
index 82abc9ecf..1f69f2e0c 100644
--- a/server/src/core/contest-award-assignment.ts
+++ b/server/src/core/contest-award-assignment.ts
@@ -1,5 +1,4 @@
 import { gql } from 'apollo-server-core';
-import { Resolvers } from '../main/resolver-types';
 import { ContestProblemAssignment } from './contest-problem-assignment';
 import { Award } from './material/award';
 
@@ -17,20 +16,14 @@ export const contestAwardAssignmentSchema = gql`
     }
 `;
 
-export interface ContestAwardAssignment {
-    __typename: 'ContestAwardAssignment';
-    problemAssignment: ContestProblemAssignment;
-    award: Award;
+export class ContestAwardAssignment {
+    constructor(readonly problemAssignment: ContestProblemAssignment, readonly award: Award) {}
+    __typename = 'ContestAwardAssignment';
+    id() {
+        return `${this.problemAssignment.problem.contest.id}/${this.problemAssignment.problem.name}/${this.award.index}`;
+    }
 }
 
 export interface ContestAwardAssignmentModelRecord {
     ContestAwardAssignment: ContestAwardAssignment;
 }
-
-export const contestAwardAssignmentResolvers: Resolvers = {
-    ContestAwardAssignment: {
-        id: a => `${a.problemAssignment.problem.contest.id}/${a.problemAssignment.problem.name}/${a.award.index}`,
-        problemAssignment: a => a.problemAssignment,
-        award: a => a.award,
-    },
-};
diff --git a/server/src/core/contest-problem-assignment-user-tackling.ts b/server/src/core/contest-problem-assignment-user-tackling.ts
index 7e19fe948..eb2a7d03d 100644
--- a/server/src/core/contest-problem-assignment-user-tackling.ts
+++ b/server/src/core/contest-problem-assignment-user-tackling.ts
@@ -1,16 +1,11 @@
 import { gql } from 'apollo-server-core';
-import { ApiObject } from '../main/api';
-import { Resolvers } from '../main/resolver-types';
-import { typed } from '../util/types';
-import { ContestApi } from './contest';
-import {
-    ContestAwardAssignmentUserTackling,
-    ContestAwardAssignmentUserTacklingApi,
-} from './contest-award-assignment-user-tackling';
+import { ApiContext } from '../main/api-context';
+import { ContestAwardAssignment } from './contest-award-assignment';
+import { ContestAwardAssignmentUserTackling } from './contest-award-assignment-user-tackling';
 import { ContestProblemAssignment } from './contest-problem-assignment';
 import { ScoreGrade } from './feedback/score';
 import { ProblemMaterialApi } from './material/problem-material';
-import { SubmissionApi } from './submission';
+import { SubmissionCache } from './submission';
 import { User } from './user';
 import { ContestProblemAssignmentView } from './view/contest-problem-assignment-view';
 
@@ -36,51 +31,62 @@ export const contestProblemAssignmentUserTacklingSchema = gql`
     }
 `;
 
-export interface ContestProblemAssignmentUserTackling {
-    __typename: 'ContestProblemAssignmentUserTackling';
-    assignment: ContestProblemAssignment;
-    user: User;
-}
+export class ContestProblemAssignmentUserTackling {
+    constructor(readonly assignment: ContestProblemAssignment, readonly user: User, readonly ctx: ApiContext) {}
 
-export interface ContestProblemAssignmentUserTacklingModelRecord {
-    ContestProblemAssignmentUserTackling: ContestProblemAssignmentUserTackling;
-}
+    __typename = 'ContestProblemAssignmentUserTackling';
+
+    id(): string {
+        return `${this.assignment.id()}/${this.user.id()}`;
+    }
+
+    static fromId(id: string, ctx: ApiContext): ContestProblemAssignmentUserTackling {
+        const ids = id.split('/');
+        const assignmentId = ids.splice(0, 2).join('/');
+        const userId = ids.join('/');
+
+        return new ContestProblemAssignmentUserTackling(
+            ContestProblemAssignment.fromId(assignmentId, ctx),
+            User.fromId(userId, ctx),
+            ctx,
+        );
+    }
 
-export class ContestProblemAssignmentUserTacklingApi extends ApiObject {
-    async canSubmit(t: ContestProblemAssignmentUserTackling) {
-        const status = await this.ctx.api(ContestApi).getStatus(t.assignment.problem.contest);
+    async canSubmit() {
+        const status = await this.assignment.problem.contest.getStatus();
 
         return status === 'RUNNING';
     }
 
-    async getAwardTacklings({ assignment, user }: ContestProblemAssignmentUserTackling) {
-        const material = await this.ctx.api(ProblemMaterialApi).dataLoader.load(assignment.problem);
+    async submissions() {
+        return this.ctx.api(SubmissionCache).allByTackling.load(this.id());
+    }
 
-        return material.awards.map(award =>
-            typed<ContestAwardAssignmentUserTackling>({
-                __typename: 'ContestAwardAssignmentUserTackling',
-                assignment: { __typename: 'ContestAwardAssignment', problemAssignment: assignment, award },
-                user,
-            }),
-        );
+    assignmentView() {
+        return new ContestProblemAssignmentView(this.assignment, this.user, this.ctx);
     }
 
-    async getScoreGrade(t: ContestProblemAssignmentUserTackling) {
-        const awardTacklings = await this.getAwardTacklings(t);
-        const awardGrades = await Promise.all(
-            awardTacklings.map(t2 => this.ctx.api(ContestAwardAssignmentUserTacklingApi).getGrade(t2)),
+    async getAwardTacklings() {
+        const material = await this.ctx.api(ProblemMaterialApi).dataLoader.load(this.assignment.problem.id());
+
+        return material.awards.map(
+            award =>
+                new ContestAwardAssignmentUserTackling(
+                    new ContestAwardAssignment(this.assignment, award),
+                    this.user,
+                    this.ctx,
+                ),
         );
+    }
+
+    async getScoreGrade() {
+        const awardTacklings = await this.getAwardTacklings();
+        const awardGrades = await Promise.all(awardTacklings.map(t2 => t2.getGrade()));
 
         return ScoreGrade.total(awardGrades.filter((g): g is ScoreGrade => g instanceof ScoreGrade));
     }
 }
 
-export const contestProblemAssignmentUserTacklingResolvers: Resolvers = {
-    ContestProblemAssignmentUserTackling: {
-        canSubmit: async (t, {}, ctx) => ctx.api(ContestProblemAssignmentUserTacklingApi).canSubmit(t),
-        submissions: async (t, {}, ctx) => ctx.api(SubmissionApi).allByTackling.load(t),
-        assignmentView: ({ assignment, user }) =>
-            typed<ContestProblemAssignmentView>({ __typename: 'ContestProblemAssignmentView', assignment, user }),
-        user: ({ user }) => user,
-    },
-};
+export interface ContestProblemAssignmentUserTacklingModelRecord {
+    ContestProblemAssignmentUserTackling: ContestProblemAssignmentUserTackling;
+}
diff --git a/server/src/core/contest-problem-assignment.ts b/server/src/core/contest-problem-assignment.ts
index bbf454e1f..1a40b950e 100644
--- a/server/src/core/contest-problem-assignment.ts
+++ b/server/src/core/contest-problem-assignment.ts
@@ -1,5 +1,5 @@
 import { gql } from 'apollo-server-core';
-import { Resolvers } from '../main/resolver-types';
+import { ApiContext } from '../main/api-context';
 import { Problem } from './problem';
 
 export const contestProblemAssignmentSchema = gql`
@@ -11,19 +11,20 @@ export const contestProblemAssignmentSchema = gql`
     }
 `;
 
-export interface ContestProblemAssignment {
-    __typename: 'ContestProblemAssignment';
-    problem: Problem;
+export class ContestProblemAssignment {
+    constructor(readonly problem: Problem) {}
+    __typename = 'ContestProblemAssignment';
+    id() {
+        return `${this.problem.contest.id}/${this.problem.name}`;
+    }
+    contest() {
+        return this.problem.contest;
+    }
+    static fromId(id: string, ctx: ApiContext): ContestProblemAssignment {
+        return new ContestProblemAssignment(Problem.fromId(id, ctx));
+    }
 }
 
 export interface ContestProblemAssignmentModelRecord {
     ContestProblemAssignment: ContestProblemAssignment;
 }
-
-export const contestProblemAssignmentResolvers: Resolvers = {
-    ContestProblemAssignment: {
-        id: a => `${a.problem.contest.id}/${a.problem.name}`,
-        contest: (a, {}, ctx) => a.problem.contest,
-        problem: (a, {}, ctx) => a.problem,
-    },
-};
diff --git a/server/src/core/contest-problem-set-user-tackling.ts b/server/src/core/contest-problem-set-user-tackling.ts
index 437f346ce..8e68ec6e9 100644
--- a/server/src/core/contest-problem-set-user-tackling.ts
+++ b/server/src/core/contest-problem-set-user-tackling.ts
@@ -1,11 +1,10 @@
 import { gql } from 'apollo-server-core';
-import { ApiObject } from '../main/api';
-import { Resolvers } from '../main/resolver-types';
-import { ContestApi } from './contest';
-import { ContestProblemAssignmentUserTacklingApi } from './contest-problem-assignment-user-tackling';
+import { ApiContext } from '../main/api-context';
+import { ContestProblemAssignmentUserTackling } from './contest-problem-assignment-user-tackling';
 import { ContestProblemSet } from './contest-problem-set';
 import { ScoreGrade } from './feedback/score';
 import { User } from './user';
+import { ContestProblemSetView } from './view/contest-problem-set-view';
 
 export const contestProblemSetUserTacklingSchema = gql`
     """
@@ -22,38 +21,28 @@ export const contestProblemSetUserTacklingSchema = gql`
     }
 `;
 
-export interface ContestProblemSetUserTackling {
-    __typename: 'ContestProblemSetUserTackling';
-    problemSet: ContestProblemSet;
-    user: User;
-}
+export class ContestProblemSetUserTackling {
+    constructor(readonly problemSet: ContestProblemSet, readonly user: User, readonly ctx: ApiContext) {}
 
-export interface ContestProblemSetUserTacklingModelRecord {
-    ContestProblemSetUserTackling: ContestProblemSetUserTackling;
-}
+    __typename = 'ContestProblemSetUserTackling';
 
-export class ContestProblemSetUserTacklingApi extends ApiObject {
-    async getScoreGrade({ problemSet, user }: ContestProblemSetUserTackling) {
-        const assignments = await this.ctx.api(ContestApi).getProblemAssignments(problemSet.contest);
+    view() {
+        return new ContestProblemSetView(this.problemSet, this.user, this.ctx);
+    }
+
+    async getScoreGrade() {
+        const assignments = await this.problemSet.contest.getProblemAssignments();
 
         return ScoreGrade.total(
             await Promise.all(
                 assignments.map(async assignment =>
-                    this.ctx.api(ContestProblemAssignmentUserTacklingApi).getScoreGrade({
-                        __typename: 'ContestProblemAssignmentUserTackling',
-                        assignment,
-                        user,
-                    }),
+                    new ContestProblemAssignmentUserTackling(assignment, this.user, this.ctx).getScoreGrade(),
                 ),
             ),
         );
     }
 }
 
-export const contestAssignmentUserTacklingResolvers: Resolvers = {
-    ContestProblemSetUserTackling: {
-        problemSet: ({ problemSet }) => problemSet,
-        user: ({ user }) => user,
-        view: ({ problemSet, user }) => ({ __typename: 'ContestProblemSetView', problemSet, user }),
-    },
-};
+export interface ContestProblemSetUserTacklingModelRecord {
+    ContestProblemSetUserTackling: ContestProblemSetUserTackling;
+}
diff --git a/server/src/core/contest-problem-set.ts b/server/src/core/contest-problem-set.ts
index 6bd4960ad..2e0ae61fb 100644
--- a/server/src/core/contest-problem-set.ts
+++ b/server/src/core/contest-problem-set.ts
@@ -1,7 +1,5 @@
 import { gql } from 'apollo-server-core';
-import { ApiObject } from '../main/api';
-import { Resolvers } from '../main/resolver-types';
-import { Contest, ContestApi } from './contest';
+import { Contest } from './contest';
 import { ScoreRange } from './feedback/score';
 
 export const contestProblemSetSchema = gql`
@@ -24,26 +22,22 @@ export const contestProblemSetSchema = gql`
     }
 `;
 
-export interface ContestProblemSet {
-    __typename: 'ContestProblemSet';
-    contest: Contest;
-}
+export class ContestProblemSet {
+    constructor(readonly contest: Contest) {}
 
-export interface ContestProblemSetModelRecord {
-    ContestProblemSet: ContestProblemSet;
-}
+    __typename = 'ContestProblemSet';
 
-export class ContestProblemSetApi extends ApiObject {
-    async getScoreRange(s: ContestProblemSet): Promise<ScoreRange> {
-        const material = await this.ctx.api(ContestApi).getProblemSetMaterial(s.contest);
+    async assignments() {
+        return this.contest.getProblemAssignments();
+    }
+
+    async getScoreRange(): Promise<ScoreRange> {
+        const material = await this.contest.getProblemSetMaterial();
 
         return ScoreRange.total(material.map(m => m.scoreRange));
     }
 }
 
-export const contestProblemSetResolvers: Resolvers = {
-    ContestProblemSet: {
-        contest: s => s.contest,
-        assignments: (s, {}, ctx) => ctx.api(ContestApi).getProblemAssignments(s.contest),
-    },
-};
+export interface ContestProblemSetModelRecord {
+    ContestProblemSet: ContestProblemSet;
+}
diff --git a/server/src/core/contest.ts b/server/src/core/contest.ts
index 1d1495a46..5edddf187 100644
--- a/server/src/core/contest.ts
+++ b/server/src/core/contest.ts
@@ -6,15 +6,18 @@ import { AllowNull, Column, DataType, ForeignKey, Table } from 'sequelize-typesc
 import * as yaml from 'yaml';
 import { __generated_ContestStatus } from '../generated/graphql-types';
 import { ApiObject } from '../main/api';
+import { ApiContext } from '../main/api-context';
 import { createSimpleLoader, UuidBaseModel } from '../main/base-model';
-import { Resolvers } from '../main/resolver-types';
-import { typed } from '../util/types';
 import { ContestMetadata } from './contest-metadata';
 import { ContestProblemAssignment } from './contest-problem-assignment';
+import { ContestProblemSet } from './contest-problem-set';
 import { Archive } from './files/archive';
 import { FileContent } from './files/file-content';
-import { Media, MediaVariant } from './material/media';
+import { Media, MediaFile } from './material/media';
 import { ProblemMaterial, ProblemMaterialApi } from './material/problem-material';
+import { Text } from './material/text';
+import { Problem } from './problem';
+import { User } from './user';
 
 export const contestSchema = gql`
     type Contest {
@@ -47,11 +50,10 @@ export class ContestData extends UuidBaseModel<ContestData> {
     @Column(DataType.UUIDV4)
     @ForeignKey(() => Archive)
     archiveId!: string;
-}
 
-export interface Contest {
-    __typename: 'Contest';
-    id: string;
+    getContest(ctx: ApiContext) {
+        return new Contest(this.id, ctx);
+    }
 }
 
 export type ContestStatus = __generated_ContestStatus;
@@ -65,44 +67,78 @@ export interface ContestModelRecord {
 }
 
 export class ContestApi extends ApiObject {
-    fromId(id: string): Contest {
-        return { __typename: 'Contest', id };
-    }
-
-    fromData(data: ContestData): Contest {
-        return this.fromId(data.id);
-    }
-
-    dataLoader = createSimpleLoader(({ id }: Contest) => this.ctx.table(ContestData).findByPk(id));
-
-    async validate(contest: Contest) {
-        await this.dataLoader.load(contest);
-
-        return contest;
-    }
+    dataLoader = createSimpleLoader((id: string) => this.ctx.table(ContestData).findByPk(id));
 
     byNameLoader = createSimpleLoader(async (name: string) => {
         // FIXME: should contests be addressable by name anyway?
 
         const contests = await this.ctx.table(ContestData).findAll();
-        const metadata = await Promise.all(contests.map(d => this.fromData(d)).map(async c => this.getMetadata(c)));
+        const metadata = await Promise.all(contests.map(d => d.getContest(this.ctx)).map(async c => c.getMetadata()));
 
         return contests.find((c, i) => metadata[i].name === name) ?? null;
     });
 
-    async getDefault(): Promise<Contest | null> {
-        // FIXME: assumes there is only one contest
-        const data = await this.ctx.table(ContestData).findOne();
-        if (data === null) return null;
+    private statementVariantFromFile(archiveFile: Archive): MediaFile {
+        const type = mime.lookup(archiveFile.path);
+        const pathParts = archiveFile.path.split('/');
 
-        return {
-            __typename: 'Contest',
-            id: data.id,
-        };
+        return new MediaFile(
+            pathParts[pathParts.length - 1],
+            null,
+            type !== false ? type : 'application/octet-stream',
+            archiveFile.getContent(),
+            this.ctx,
+        );
     }
 
-    async getMetadata(c: Contest) {
-        const { archiveId } = await this.dataLoader.load(c);
+    async getStatement(c: Contest): Promise<Media> {
+        const { archiveId } = await this.dataLoader.load(c.id);
+        const statementFiles = await this.ctx.table(Archive).findAll({
+            where: {
+                uuid: archiveId,
+                path: {
+                    [Op.like]: 'files/home%',
+                },
+            },
+        });
+
+        return new Media(statementFiles.map((archiveFile): MediaFile => this.statementVariantFromFile(archiveFile)));
+    }
+}
+
+export class Contest {
+    constructor(readonly id: string, readonly ctx: ApiContext) {}
+
+    __typename = 'Contest';
+    async name() {
+        return (await this.getMetadata()).name;
+    }
+    async title() {
+        return new Text([{ value: (await this.getMetadata()).title }]);
+    }
+    async start() {
+        return (await this.getMetadata()).start;
+    }
+    async end() {
+        return (await this.getMetadata()).end ?? null;
+    }
+    async status() {
+        return this.getStatus();
+    }
+    problemSet() {
+        return new ContestProblemSet(this);
+    }
+    async archive() {
+        await this.ctx.authorizeAdmin();
+
+        return { uuid: (await this.ctx.api(ContestApi).dataLoader.load(this.id)).archiveId };
+    }
+    async statement() {
+        return this.ctx.api(ContestApi).getStatement(this);
+    }
+
+    async getMetadata() {
+        const { archiveId } = await this.ctx.api(ContestApi).dataLoader.load(this.id);
         const metadataPath = `turingarena.yaml`;
         const metadataProblemFile = await this.ctx.table(Archive).findOne({
             where: {
@@ -112,7 +148,7 @@ export class ContestApi extends ApiObject {
         });
 
         if (metadataProblemFile === null) {
-            throw new Error(`Contest ${c.id} is missing metadata file ${metadataPath}`);
+            throw new Error(`Contest ${this.id} is missing metadata file ${metadataPath}`);
         }
 
         const metadataContent = await this.ctx
@@ -122,8 +158,8 @@ export class ContestApi extends ApiObject {
         return yaml.parse(metadataContent!.content.toString()) as ContestMetadata;
     }
 
-    async getStatus(c: Contest): Promise<ContestStatus> {
-        const metadata = await this.getMetadata(c);
+    async getStatus(): Promise<ContestStatus> {
+        const metadata = await this.getMetadata();
 
         const start = DateTime.fromISO(metadata.start).valueOf();
         const end = metadata.end !== undefined ? DateTime.fromISO(metadata.end).valueOf() : null;
@@ -134,65 +170,41 @@ export class ContestApi extends ApiObject {
         else return 'ENDED';
     }
 
-    async getProblemAssignments(contest: Contest) {
-        const metadata = await this.getMetadata(contest);
+    async getProblemAssignments() {
+        const metadata = await this.getMetadata();
 
-        return metadata.problems.map(name =>
-            typed<ContestProblemAssignment>({
-                __typename: 'ContestProblemAssignment',
-                problem: { __typename: 'Problem', contest, name },
-            }),
-        );
+        return metadata.problems.map(name => new ContestProblemAssignment(new Problem(this, name, this.ctx)));
     }
 
-    async getProblemSetMaterial(c: Contest): Promise<ProblemMaterial[]> {
-        const assignments = await this.getProblemAssignments(c);
+    async getProblemSetMaterial(): Promise<ProblemMaterial[]> {
+        const assignments = await this.getProblemAssignments();
 
         return Promise.all(
-            assignments.map(async ({ problem }) => this.ctx.api(ProblemMaterialApi).dataLoader.load(problem)),
+            assignments.map(async ({ problem }) => this.ctx.api(ProblemMaterialApi).dataLoader.load(problem.id())),
         );
     }
 
-    private statementVariantFromFile(archiveFile: Archive): MediaVariant {
-        const type = mime.lookup(archiveFile.path);
-        const pathParts = archiveFile.path.split('/');
+    async validate() {
+        await this.ctx.api(ContestApi).dataLoader.load(this.id);
 
-        return {
-            name: pathParts[pathParts.length - 1],
-            type: type !== false ? type : 'application/octet-stream',
-            content: () => archiveFile.getContent(),
-        };
+        return this;
     }
 
-    async getStatement(c: Contest): Promise<Media> {
-        const { archiveId } = await this.dataLoader.load(c);
-        const statementFiles = await this.ctx.table(Archive).findAll({
-            where: {
-                uuid: archiveId,
-                path: {
-                    [Op.like]: 'files/home%',
-                },
-            },
-        });
+    static async getDefault(ctx: ApiContext): Promise<Contest | null> {
+        // FIXME: assumes there is only one contest
+        const data = await ctx.table(ContestData).findOne();
+        if (data === null) return null;
 
-        return statementFiles.map((archiveFile): MediaVariant => this.statementVariantFromFile(archiveFile));
+        return data.getContest(ctx);
     }
-}
 
-export const contestResolvers: Resolvers = {
-    Contest: {
-        id: c => c.id,
-        name: async (c, {}, ctx) => (await ctx.api(ContestApi).getMetadata(c)).name,
-        title: async (c, {}, ctx) => [{ value: (await ctx.api(ContestApi).getMetadata(c)).title }],
-        start: async (c, {}, ctx) => (await ctx.api(ContestApi).getMetadata(c)).start,
-        end: async (c, {}, ctx) => (await ctx.api(ContestApi).getMetadata(c)).end ?? null,
-        status: (c, {}, ctx) => ctx.api(ContestApi).getStatus(c),
-        problemSet: c => ({ __typename: 'ContestProblemSet', contest: c }),
-        archive: async (c, {}, ctx) => {
-            await ctx.authorizeAdmin();
-
-            return { uuid: (await ctx.api(ContestApi).dataLoader.load(c)).archiveId };
-        },
-        statement: (c, {}, ctx) => ctx.api(ContestApi).getStatement(c),
-    },
-};
+    async getUserByToken(token: string) {
+        const contestMetadata = await this.getMetadata();
+        const userMetadata = contestMetadata.users.find(data => data.token === token) ?? null;
+
+        if (userMetadata === null) return null;
+        const { username } = userMetadata;
+
+        return new User(this, username, this.ctx);
+    }
+}
diff --git a/server/src/core/view/contest-award-assignment-view.ts b/server/src/core/view/contest-award-assignment-view.ts
index 778e0bd5f..1d5e1fa3a 100644
--- a/server/src/core/view/contest-award-assignment-view.ts
+++ b/server/src/core/view/contest-award-assignment-view.ts
@@ -1,12 +1,7 @@
 import { gql } from 'apollo-server-core';
-import { ApiObject } from '../../main/api';
-import { Resolvers } from '../../main/resolver-types';
-import { typed } from '../../util/types';
+import { ApiContext } from '../../main/api-context';
 import { ContestAwardAssignment } from '../contest-award-assignment';
-import {
-    ContestAwardAssignmentUserTackling,
-    ContestAwardAssignmentUserTacklingApi,
-} from '../contest-award-assignment-user-tackling';
+import { ContestAwardAssignmentUserTackling } from '../contest-award-assignment-user-tackling';
 import { FulfillmentField, FulfillmentGradeDomain } from '../feedback/fulfillment';
 import { ScoreField, ScoreGradeDomain } from '../feedback/score';
 import { User } from '../user';
@@ -29,63 +24,41 @@ export const contestAwardAssignmentViewSchema = gql`
     }
 `;
 
-export interface ContestAwardAssignmentView {
-    __typename: 'ContestAwardAssignmentView';
-    assignment: ContestAwardAssignment;
-    user: User | null;
-}
-
-export interface ContestAwardAssignmentViewModelRecord {
-    ContestAwardAssignmentView: ContestAwardAssignmentView;
-}
+export class ContestAwardAssignmentView {
+    constructor(readonly assignment: ContestAwardAssignment, readonly user: User | null, readonly ctx: ApiContext) {}
 
-export class ContestAwardAssignmentViewApi extends ApiObject {
-    getTackling({ assignment, user }: ContestAwardAssignmentView) {
-        if (user === null) return null;
+    __typename = 'ContestAwardAssignmentView';
 
-        return typed<ContestAwardAssignmentUserTackling>({
-            __typename: 'ContestAwardAssignmentUserTackling',
-            assignment,
-            user,
-        });
+    async problemAssignmentView() {
+        return new ContestProblemAssignmentView(this.assignment.problemAssignment, this.user, this.ctx);
     }
 
-    async getGradeField(v: ContestAwardAssignmentView) {
-        const { gradeDomain: domain } = v.assignment.award;
-        const tackling = this.getTackling(v);
+    async gradeField() {
+        const { gradeDomain: domain } = this.assignment.award;
+        const tackling = this.getTackling();
 
         if (domain instanceof FulfillmentGradeDomain) {
-            const grade =
-                tackling !== null
-                    ? await this.ctx.api(ContestAwardAssignmentUserTacklingApi).getFulfillmentGrade(tackling)
-                    : null;
+            const grade = tackling !== null ? await tackling.getFulfillmentGrade() : null;
 
             return new FulfillmentField(grade?.fulfilled ?? null);
         }
 
         if (domain instanceof ScoreGradeDomain) {
-            const grade =
-                tackling !== null
-                    ? await this.ctx.api(ContestAwardAssignmentUserTacklingApi).getScoreGrade(tackling, domain)
-                    : null;
+            const grade = tackling !== null ? await tackling.getScoreGrade(domain) : null;
 
             return new ScoreField(domain.scoreRange, grade?.score ?? null);
         }
 
         throw new Error(`unexpected grade domain ${domain}`);
     }
+
+    getTackling() {
+        if (this.user === null) return null;
+
+        return new ContestAwardAssignmentUserTackling(this.assignment, this.user, this.ctx);
+    }
 }
 
-export const contestAwardAssignmentViewResolvers: Resolvers = {
-    ContestAwardAssignmentView: {
-        assignment: v => v.assignment,
-        user: v => v.user,
-        problemAssignmentView: async ({ assignment, user }) =>
-            typed<ContestProblemAssignmentView>({
-                __typename: 'ContestProblemAssignmentView',
-                assignment: assignment.problemAssignment,
-                user,
-            }),
-        gradeField: (v, {}, ctx) => ctx.api(ContestAwardAssignmentViewApi).getGradeField(v),
-    },
-};
+export interface ContestAwardAssignmentViewModelRecord {
+    ContestAwardAssignmentView: ContestAwardAssignmentView;
+}
diff --git a/server/src/core/view/contest-problem-assignment-view.ts b/server/src/core/view/contest-problem-assignment-view.ts
index 83e3a816c..d4106cd8f 100644
--- a/server/src/core/view/contest-problem-assignment-view.ts
+++ b/server/src/core/view/contest-problem-assignment-view.ts
@@ -1,12 +1,9 @@
 import { gql } from 'apollo-server-core';
-import { ApiObject } from '../../main/api';
-import { Resolvers } from '../../main/resolver-types';
-import { typed } from '../../util/types';
+import { ApiContext } from '../../main/api-context';
+import { ContestAwardAssignment } from '../contest-award-assignment';
 import { ContestProblemAssignment } from '../contest-problem-assignment';
-import {
-    ContestProblemAssignmentUserTackling,
-    ContestProblemAssignmentUserTacklingApi,
-} from '../contest-problem-assignment-user-tackling';
+import { ContestProblemAssignmentUserTackling } from '../contest-problem-assignment-user-tackling';
+import { ContestProblemSet } from '../contest-problem-set';
 import { ScoreField } from '../feedback/score';
 import { ProblemMaterialApi } from '../material/problem-material';
 import { User } from '../user';
@@ -41,70 +38,40 @@ export const contestProblemAssignmentViewSchema = gql`
     }
 `;
 
-export interface ContestProblemAssignmentView {
-    __typename: 'ContestProblemAssignmentView';
-    assignment: ContestProblemAssignment;
-    user: User | null;
-}
+export class ContestProblemAssignmentView {
+    constructor(readonly assignment: ContestProblemAssignment, readonly user: User | null, readonly ctx: ApiContext) {}
 
-export interface ContestProblemAssignmentViewModelRecord {
-    ContestProblemAssignmentView: ContestProblemAssignmentView;
-}
+    __typename = 'ContestProblemAssignmentView';
+
+    async problemSetView() {
+        return new ContestProblemSetView(new ContestProblemSet(this.assignment.problem.contest), this.user, this.ctx);
+    }
 
-export class ContestProblemAssignmentViewApi extends ApiObject {
-    getTackling({ assignment, user }: ContestProblemAssignmentView) {
-        return user !== null
-            ? typed<ContestProblemAssignmentUserTackling>({
-                  __typename: 'ContestProblemAssignmentUserTackling',
-                  assignment,
-                  user,
-              })
+    async tackling() {
+        return this.user !== null
+            ? new ContestProblemAssignmentUserTackling(this.assignment, this.user, this.ctx)
             : null;
     }
 
-    async getTotalScoreField(view: ContestProblemAssignmentView) {
-        const { scoreRange } = await this.ctx.api(ProblemMaterialApi).dataLoader.load(view.assignment.problem);
-        const tackling = this.getTackling(view);
+    async totalScoreField() {
+        const { scoreRange } = await this.ctx.api(ProblemMaterialApi).dataLoader.load(this.assignment.problem.id());
+        const tackling = await this.tackling();
 
-        const scoreGrade =
-            tackling !== null
-                ? await this.ctx.api(ContestProblemAssignmentUserTacklingApi).getScoreGrade(tackling)
-                : null;
+        const scoreGrade = tackling !== null ? await tackling.getScoreGrade() : null;
 
         return new ScoreField(scoreRange, scoreGrade?.score ?? null);
     }
 
-    async getAwardAssignmentViews({ assignment, user }: ContestProblemAssignmentView) {
-        const { awards } = await this.ctx.api(ProblemMaterialApi).dataLoader.load(assignment.problem);
+    async awardAssignmentViews() {
+        const { awards } = await this.ctx.api(ProblemMaterialApi).dataLoader.load(this.assignment.problem.id());
 
-        return awards.map(award =>
-            typed<ContestAwardAssignmentView>({
-                __typename: 'ContestAwardAssignmentView',
-                assignment: { __typename: 'ContestAwardAssignment', award, problemAssignment: assignment },
-                user,
-            }),
+        return awards.map(
+            award =>
+                new ContestAwardAssignmentView(new ContestAwardAssignment(this.assignment, award), this.user, this.ctx),
         );
     }
-
-    async getProblemSetView({ user, assignment }: ContestProblemAssignmentView) {
-        return typed<ContestProblemSetView>({
-            __typename: 'ContestProblemSetView',
-            problemSet: {
-                __typename: 'ContestProblemSet',
-                contest: assignment.problem.contest,
-            },
-            user,
-        });
-    }
 }
 
-export const contestProblemAssignmentViewResolvers: Resolvers = {
-    ContestProblemAssignmentView: {
-        assignment: v => v.assignment,
-        user: v => v.user,
-        problemSetView: async (v, {}, ctx) => ctx.api(ContestProblemAssignmentViewApi).getProblemSetView(v),
-        tackling: async (v, {}, ctx) => ctx.api(ContestProblemAssignmentViewApi).getTackling(v),
-        totalScoreField: async (v, {}, ctx) => ctx.api(ContestProblemAssignmentViewApi).getTotalScoreField(v),
-        awardAssignmentViews: async (v, {}, ctx) => ctx.api(ContestProblemAssignmentViewApi).getAwardAssignmentViews(v),
-    },
-};
+export interface ContestProblemAssignmentViewModelRecord {
+    ContestProblemAssignmentView: ContestProblemAssignmentView;
+}
diff --git a/server/src/core/view/contest-problem-set-view.ts b/server/src/core/view/contest-problem-set-view.ts
index 86424d98d..297c1ff3a 100644
--- a/server/src/core/view/contest-problem-set-view.ts
+++ b/server/src/core/view/contest-problem-set-view.ts
@@ -1,10 +1,7 @@
 import { gql } from 'apollo-server-core';
-import { ApiObject } from '../../main/api';
-import { Resolvers } from '../../main/resolver-types';
-import { typed } from '../../util/types';
-import { ContestApi } from '../contest';
-import { ContestProblemSet, ContestProblemSetApi } from '../contest-problem-set';
-import { ContestProblemSetUserTackling, ContestProblemSetUserTacklingApi } from '../contest-problem-set-user-tackling';
+import { ApiContext } from '../../main/api-context';
+import { ContestProblemSet } from '../contest-problem-set';
+import { ContestProblemSetUserTackling } from '../contest-problem-set-user-tackling';
 import { ScoreField } from '../feedback/score';
 import { User } from '../user';
 import { ContestProblemAssignmentView } from './contest-problem-assignment-view';
@@ -32,45 +29,37 @@ export const contestProblemSetViewSchema = gql`
     }
 `;
 
-export interface ContestProblemSetView {
-    __typename: 'ContestProblemSetView';
-    problemSet: ContestProblemSet;
-    user: User | null;
-}
+export class ContestProblemSetView {
+    constructor(readonly problemSet: ContestProblemSet, readonly user: User | null, readonly ctx: ApiContext) {}
 
-export interface ContestProblemSetViewModelRecord {
-    ContestProblemSetView: ContestProblemSetView;
-}
+    __typename = 'ContestProblemSetView';
 
-export class ContestProblemSetViewApi extends ApiObject {
-    getTackling({ problemSet, user }: ContestProblemSetView) {
-        if (user === null) return null;
+    contestView() {
+        return new ContestView(this.problemSet.contest, this.user, this.ctx);
+    }
 
-        return typed<ContestProblemSetUserTackling>({ __typename: 'ContestProblemSetUserTackling', problemSet, user });
+    async assignmentViews() {
+        return (await this.problemSet.contest.getProblemAssignments()).map(
+            assignment => new ContestProblemAssignmentView(assignment, this.user, this.ctx),
+        );
     }
 
-    async getTotalScoreField(v: ContestProblemSetView) {
-        const tackling = this.getTackling(v);
+    tackling() {
+        if (this.user === null) return null;
 
-        const scoreRange = await this.ctx.api(ContestProblemSetApi).getScoreRange(v.problemSet);
-        const scoreGrade =
-            tackling !== null ? await this.ctx.api(ContestProblemSetUserTacklingApi).getScoreGrade(tackling) : null;
+        return new ContestProblemSetUserTackling(this.problemSet, this.user, this.ctx);
+    }
+
+    async totalScoreField() {
+        const tackling = this.tackling();
+
+        const scoreRange = await this.problemSet.getScoreRange();
+        const scoreGrade = tackling !== null ? await tackling.getScoreGrade() : null;
 
         return new ScoreField(scoreRange, scoreGrade?.score ?? null);
     }
 }
 
-export const contestProblemSetViewResolvers: Resolvers = {
-    ContestProblemSetView: {
-        problemSet: v => v.problemSet,
-        user: v => v.user,
-        contestView: ({ problemSet: { contest }, user }) =>
-            typed<ContestView>({ __typename: 'ContestView', contest, user }),
-        assignmentViews: async ({ problemSet, user }, {}, ctx) =>
-            (await ctx.api(ContestApi).getProblemAssignments(problemSet.contest)).map(assignment =>
-                typed<ContestProblemAssignmentView>({ __typename: 'ContestProblemAssignmentView', assignment, user }),
-            ),
-        tackling: (v, {}, ctx) => ctx.api(ContestProblemSetViewApi).getTackling(v),
-        totalScoreField: async (v, {}, ctx) => ctx.api(ContestProblemSetViewApi).getTotalScoreField(v),
-    },
-};
+export interface ContestProblemSetViewModelRecord {
+    ContestProblemSetView: ContestProblemSetView;
+}
diff --git a/server/src/core/view/contest-view.ts b/server/src/core/view/contest-view.ts
index 1bb5d69e6..8dcb69d5b 100644
--- a/server/src/core/view/contest-view.ts
+++ b/server/src/core/view/contest-view.ts
@@ -1,7 +1,7 @@
 import { gql } from 'apollo-server-core';
-import { Resolvers } from '../../main/resolver-types';
-import { typed } from '../../util/types';
-import { Contest, ContestApi } from '../contest';
+import { ApiContext } from '../../main/api-context';
+import { Contest } from '../contest';
+import { ContestProblemSet } from '../contest-problem-set';
 import { User } from '../user';
 import { ContestProblemSetView } from './contest-problem-set-view';
 
@@ -20,34 +20,23 @@ export const contestViewSchema = gql`
     }
 `;
 
-export interface ContestView {
-    __typename: 'ContestView';
-    contest: Contest;
-    user: User | null;
+
+export class ContestView {
+    constructor(readonly contest: Contest, readonly user: User | null, readonly ctx: ApiContext) {}
+    __typename= 'ContestView';
+    async problemSetView() {
+        const status = await this.contest.getStatus();
+        switch (status) {
+            case 'RUNNING':
+            case 'ENDED':
+                return new ContestProblemSetView(new ContestProblemSet(this.contest), this.user, this.ctx);
+            case 'NOT_STARTED':
+            default:
+                return null;
+        }
+    }
 }
 
 export interface ContestViewModelRecord {
     ContestView: ContestView;
 }
-
-export const contestViewResolvers: Resolvers = {
-    ContestView: {
-        contest: ({ contest }) => contest,
-        user: ({ user }) => user,
-        problemSetView: async ({ contest, user }, {}, ctx) => {
-            const status = await ctx.api(ContestApi).getStatus(contest);
-            switch (status) {
-                case 'RUNNING':
-                case 'ENDED':
-                    return typed<ContestProblemSetView>({
-                        __typename: 'ContestProblemSetView',
-                        problemSet: { __typename: 'ContestProblemSet', contest },
-                        user,
-                    });
-                case 'NOT_STARTED':
-                default:
-                    return null;
-            }
-        },
-    },
-};