Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Readme.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Attest Backend Interview Question

Solution explanation is [here](Solution.md).

## Facts

* All data for this test is provided in the [survey-data](src/main/resources/survey-data) directory.
Expand Down
110 changes: 110 additions & 0 deletions Solution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# Survey Earnings Calculator

A Java application that calculates respondent earnings based on survey responses using dependency injection and caching for optimal performance.

## Project Overview

This application processes survey data to calculate how much each respondent has earned by answering questions. It demonstrates:

- **Dependency Injection** using PicoContainer
- **Caching Strategy** for efficient question lookups
- **Clean Architecture** with separation of concerns (repositories, services, caching)
- **Interface-based Design** for testability and flexibility
- **Comprehensive Unit Tests** using JUnit 5 and Mockito

## Architecture

### Design Patterns

- **Repository Pattern**: Abstracts data access logic
- **Dependency Injection**: Manages object dependencies using PicoContainer
- **Caching**: Question cache to avoid repeated survey traversals
- **Interface Segregation**: All components have interfaces for loose coupling

## Prerequisites

- Java 17 or higher
- Gradle 7.x or higher

## Building and Running

### Build the project

```bash
./gradlew build
```

### Run tests

```bash
./gradlew test
```

### Run the application

```bash
./gradlew run
```

## How It Works

### 1. Data Loading
- `SurveyRepo` loads survey data from `survey.json`
- `ResponseRepo` loads response data from `responses.json`

### 2. Question Caching
- `QuestionCacheRepo` creates a Map of question IDs to Question objects
- Cache is lazy-loaded on first use
- Eliminates need to traverse survey structure repeatedly

### 3. Earnings Calculation
- `EarningService` processes all responses
- For each response, looks up the question payout from cache
- Aggregates earnings per respondent using `RespondentEarning` model

## Testing Strategy

### Unit Tests
- **QuestionCacheRepoTest**: Tests caching behavior, lazy loading, and cache invalidation
- **EarningServiceTest**: Tests earnings calculation logic with mocked dependencies
- **RespondentEarningTest**: Tests the model's increment and formatting logic

### Test Coverage
- Repository caching logic
- Service business logic
- Edge cases (missing questions, empty responses, zero payouts)
- Mock-based isolation for true unit testing

## Key Features

### 1. Efficient Caching
The `QuestionCacheRepo` ensures questions are only loaded once:
- First call: Loads all questions from surveys into a HashMap
- Subsequent calls: O(1) lookup from cache
- Prevents repeated nested loop traversals

### 2. Dependency Injection
Using PicoContainer for automatic dependency resolution:
- No manual object creation
- Easy to swap implementations
- Simplified testing with mocks

### 3. Clean Separation of Concerns
- **Models**: Plain data objects
- **Repositories**: Data access and caching
- **Services**: Business logic
- Each layer depends only on interfaces

## Performance Considerations

- **Question Lookup**: O(1) with caching vs O(n×m) without (n=surveys, m=questions per survey)
- **Memory**: Questions cached in memory for duration of application
- **Lazy Loading**: Cache only populated when first needed

## Future Enhancements

- Add logging framework (SLF4J + Logback)
- Implement cache eviction strategies (LRU, TTL)
- Add configuration management (different data sources)
- REST API layer for web access
- Database persistence instead of JSON files
5 changes: 4 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,11 @@ sourceCompatibility = 17

dependencies {
testImplementation platform('org.junit:junit-bom:5.10.0')
testImplementation 'org.junit.jupiter:junit-jupiter'
testImplementation 'org.junit.jupiter:junit-jupiter:5.10.0'
testImplementation 'org.mockito:mockito-junit-jupiter:5.5.0'
testImplementation 'org.mockito:mockito-core:5.5.0'
implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.1'
implementation 'org.picocontainer:picocontainer:2.15'
}

test {
Expand Down
31 changes: 17 additions & 14 deletions src/main/java/com/askattest/interview/Main.java
Original file line number Diff line number Diff line change
@@ -1,24 +1,27 @@
package com.askattest.interview;

import com.askattest.interview.repository.ResponseRepo;
import com.askattest.interview.repository.SurveyRepo;

import java.io.IOException;
import java.util.Map;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.picocontainer.DefaultPicoContainer;
import org.picocontainer.MutablePicoContainer;
import com.askattest.interview.models.RespondentEarning;
import com.askattest.interview.repository.*;
import com.askattest.interview.service.*;

public class Main {
public static void main(String[] args) {
SurveyRepo surveys;
ResponseRepo responses;
try {
surveys = new SurveyRepo();
responses = new ResponseRepo();
} catch (IOException e) {
throw new RuntimeException(e);
}
MutablePicoContainer container = new DefaultPicoContainer();

container.addComponent(ISurveyRepo.class, SurveyRepo.class);
container.addComponent(IResponseRepo.class, ResponseRepo.class);
container.addComponent(IQuestionCacheRepo.class, QuestionCacheRepo.class);
container.addComponent(IEarningService.class, EarningService.class);
IEarningService earningService = container.getComponent(IEarningService.class);

Logger.getGlobal().log(Level.INFO, surveys.surveyById(200).toString());
Logger.getGlobal().log(Level.INFO, responses.responsesByRespondent(300).toString());
Map<Integer, RespondentEarning> respondentEarnings = earningService.calculateEarningsByRespondent();
respondentEarnings.values().forEach(respondentEarning ->
Logger.getGlobal().log(Level.INFO, respondentEarning.toString())
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.askattest.interview.models;

public class RespondentEarning {
private int respondent;
private int amount;
private int answers;
public RespondentEarning(int respondent){
this.respondent = respondent;
this.amount = 0;
this.answers = 0;
}
public void increment(int amount){
this.amount += amount;
this.answers++;
}
public int amount(){
return this.amount;
}
public int answers(){
return this.answers;
}
@Override
public String toString() {
return String.format("[Respondent: %d earned %d pences for their %d answers.]", this.respondent, this.amount, this.answers);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.askattest.interview.repository;

import com.askattest.interview.models.Question;
import java.util.Optional;

public interface IQuestionCacheRepo {
Optional<Question> getQuestion(int questionId);
void cacheQuestion(Question question);
void loadQuestionsFromSurveys();
void clear();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.askattest.interview.repository;

import com.askattest.interview.models.Response;

import java.util.List;

public interface IResponseRepo {
List<Response> responsesByRespondent(int respondentId);
List<Response> responsesByRespondentAndQuestion(int respondentId, int questionId);
List<Response> listResponses();
}
11 changes: 11 additions & 0 deletions src/main/java/com/askattest/interview/repository/ISurveyRepo.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.askattest.interview.repository;

import com.askattest.interview.models.Survey;

import java.util.List;
import java.util.Optional;

public interface ISurveyRepo {
Optional<Survey> surveyById(int surveyId);
List<Survey> listSurveys();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.askattest.interview.repository;

import com.askattest.interview.models.Question;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;

public class QuestionCacheRepo implements IQuestionCacheRepo {
private final Map<Integer, Question> cache = new HashMap<>();
private final ISurveyRepo surveyRepo;

public QuestionCacheRepo(ISurveyRepo surveyRepo) {
this.surveyRepo = surveyRepo;
}

@Override
public Optional<Question> getQuestion(int questionId) {
return Optional.ofNullable(cache.get(questionId));
}

@Override
public void cacheQuestion(Question question) {
cache.put(question.id, question);
}

@Override
public void loadQuestionsFromSurveys() {
if (cache.isEmpty()) {
surveyRepo.listSurveys().stream()
.flatMap(survey -> survey.questions.stream())
.forEach(question -> cache.put(question.id, question));
}
}

@Override
public void clear() {
cache.clear();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,24 @@
import java.net.URL;
import java.util.List;

public class ResponseRepo {
public class ResponseRepo implements IResponseRepo {
public List<Response> responses;

public ResponseRepo() throws IOException {
this.responses = ResponseRepo.loadResponses("responses.json");
}

@Override
public List<Response> responsesByRespondent(int respondentId) {
return this.responses.stream().filter(response -> response.respondent == respondentId).toList();
}

@Override
public List<Response> responsesByRespondentAndQuestion(int respondentId, int questionId) {
return this.responses.stream().filter(response -> response.respondent == respondentId && response.question == questionId).toList();
}

@Override
public List<Response> listResponses() {
return this.responses;
}
Expand Down
10 changes: 6 additions & 4 deletions src/main/java/com/askattest/interview/repository/SurveyRepo.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,22 @@
import java.net.URL;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;

public class SurveyRepo {
public class SurveyRepo implements ISurveyRepo{
public List<Survey> surveys;

public SurveyRepo() throws IOException {
this.surveys = Arrays.asList(SurveyRepo.loadSurvey("survey.json"));
}

public Survey surveyById(int surveyId) {
@Override
public Optional<Survey> surveyById(int surveyId) {
return surveys.stream().filter(survey -> survey.id == surveyId)
.findFirst()
.orElse(null);
.findFirst();
}

@Override
public List<Survey> listSurveys() {
return this.surveys;
}
Expand Down
49 changes: 49 additions & 0 deletions src/main/java/com/askattest/interview/service/EarningService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package com.askattest.interview.service;

import com.askattest.interview.models.RespondentEarning;
import com.askattest.interview.repository.IQuestionCacheRepo;
import com.askattest.interview.repository.IResponseRepo;
import java.util.HashMap;
import java.util.Map;

public class EarningService implements IEarningService {
private final IResponseRepo responseRepo;
private final IQuestionCacheRepo questionCache;

public EarningService(IResponseRepo responseRepo, IQuestionCacheRepo questionCache) {
this.responseRepo = responseRepo;
this.questionCache = questionCache;
}

@Override
public Map<Integer, RespondentEarning> calculateEarningsByRespondent() {
// Load questions into cache if not already loaded
questionCache.loadQuestionsFromSurveys();

Map<Integer, RespondentEarning> respondentEarnings = new HashMap<>();

responseRepo.listResponses().forEach(response -> {
questionCache.getQuestion(response.question).ifPresent(question -> {
respondentEarnings.computeIfAbsent(response.respondent, RespondentEarning::new)
.increment(question.payout);
});
});

return respondentEarnings;
}

@Override
public RespondentEarning calculateEarningsForRespondent(int respondentId) {
questionCache.loadQuestionsFromSurveys();

RespondentEarning respondentEarning = new RespondentEarning(respondentId);

responseRepo.responsesByRespondent(respondentId).forEach(response -> {
questionCache.getQuestion(response.question).ifPresent(question -> {
respondentEarning.increment(question.payout);
});
});

return respondentEarning;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.askattest.interview.service;

import com.askattest.interview.models.RespondentEarning;
import java.util.Map;

public interface IEarningService {
Map<Integer, RespondentEarning> calculateEarningsByRespondent();
RespondentEarning calculateEarningsForRespondent(int respondentId);
}
Loading