Best Practice Membangun API di Laravel dan Go: Catatan dari Proyek Nyata

7 Mei 2026 oleh Faiq Najib


Di proyek migrasi PHP ke Go yang saya kerjakan, ada phase di mana saya harus audit semua route dan controller yang sudah ada. Tujuannya sederhana: sebelum migrasi, harus pahami dulu pattern apa yang sudah bagus dan mana yang harus ditinggalkan. Hasilnya? Yah, cukup… enlightening hahaha.

Proyek ini punya profil seperti ini:

Artikel ini bukan tutorial “cara bikin API”. Ini catatan pattern dan anti-pattern yang saya temukan, plus bagaimana pattern yang sama diimplementasi di Go. Jadi kalau kamu lagi migrate atau mulai proyek baru, hopefully bisa skip beberapa kesalahan yang sudah saya temukan hehe~

1. Route Organization: Kelompokkan dengan Jelas #

Apa yang Saya Temukan #

Proyek ini punya struktur route grouping yang actually cukup bagus. Auth guard dipisah per domain:

// routes/api.php - Struktur yang bagus
Route::group(['middleware' => ['auth:user-api']], function() {
    // 300+ route untuk user biasa
});

Route::group(['middleware' => ['auth:partner-api']], function() {
    // Route untuk partner/reseller
});

Route::group(['middleware' => ['auth:open-api']], function() {
    // Route untuk integrasi pihak ketiga
});

Rate limiting juga diterapkan secara strategic:

// AI analysis yang berat → rate limit ketat
Route::get('/devices/get_aicam_reports_ai', 'DeviceController@getAiCamReportsAIAnalysis')
    ->middleware('throttle:5,1');

// Command ke device → rate limit sedang
Route::post('/devices/{id}/cut_off_engine', 'DeviceController@sendFlameOffCommand')
    ->middleware('throttle:10,1');

// Google geocoding → rate limit longgar (tapi tetap ada)
Route::get('/google/reverse_geocode','GoogleController@reverseGeocodeLocation')
    ->middleware('throttle:100,1');

Masalah yang Ditemukan #

Tapi ada beberapa masalah:

Masalah 1: Naming inconsistency. Ada route yang pakai snake_case, ada yang pakai kebab-case, ada yang pakai verb:

// Campur aduk
Route::get('/device_geofence', ...);        // snake_case
Route::get('/all_devices', ...);             // snake_case
Route::put('/update_public_key', ...);       // verb + snake_case
Route::get('/shared_device', ...);           // snake_case, tapi public
Route::resource('devices', 'DeviceController'); // RESTful
Route::post('/devices/check_device_type', ...);  // verb di bawah resource

Masalah 2: Controller yang terlalu banyak tanggung jawab. DeviceController punya 40+ method dan 2000+ baris kode. Dia menangani CRUD, lokasi, laporan, perintah, dashboard, dan AI. One controller to rule them all hahaha.

Best Practice: Struktur Route di Go #

Di Go, route grouping dan middleware jauh lebih explicit:

// internal/router/router.go
func SetupRouter(
    userHandler *handler.UserHandler,
    deviceHandler *handler.DeviceHandler,
    authMiddleware middleware.AuthMiddleware,
    rateLimiter middleware.RateLimiter,
) *gin.Engine {
    r := gin.New()

    // Public routes
    r.POST("/login", userHandler.Login)
    r.POST("/register", userHandler.Register)

    // User API group
    userAPI := r.Group("/")
    userAPI.Use(authMiddleware.Guard("user-api"))
    {
        devices := userAPI.Group("/devices")
        {
            devices.GET("", deviceHandler.Index)
            devices.GET("/:id", deviceHandler.Show)
            devices.POST("", deviceHandler.Store)
            devices.PUT("/:id", deviceHandler.Update)
            devices.DELETE("/:id", deviceHandler.Destroy)

            // Device actions (sub-group dengan rate limiter)
            actions := devices.Group("/:id")
            actions.Use(rateLimiter.Limit(10, time.Minute))
            {
                actions.POST("/cut-off-engine", deviceHandler.CutOffEngine)
                actions.POST("/turn-on-engine", deviceHandler.TurnOnEngine)
            }
        }
    }

    // Partner API group
    partnerAPI := r.Group("/partner")
    partnerAPI.Use(authMiddleware.Guard("partner-api"))
    {
        partnerAPI.POST("/user-submit", partnerHandler.SubmitUser)
        partnerAPI.POST("/generate-invoice", partnerHandler.GenerateInvoice)
    }

    return r
}

Kenapa ini lebih baik? Karena di Go, setiap middleware chain dan route group adalah kode eksplisit, bukan konfigurasi array. Kamu bisa trace flow-nya secara linear, dan IDE bisa navigate langsung ke handler-nya. Tidak ada “lho, ini route pakai middleware apa ya?” karena semuanya visible hehe~

2. Controller Pattern: Kurangi, Pisahkan, Inject #

Anti-Pattern: Fat Controller #

Mari kita lihat real code dari proyek ini. Ini contoh pattern yang paling sering muncul (dan sebaiknya ditinggalkan):

// app/Http/Controllers/Auth/LoginController.php
// Pattern: validasi manual, business logic di controller

public function login(Request $request)
{
    // Validasi manual di controller
    $validator = Validator::make($request->all(),[
        'email' => 'required|string',
        'password' => 'required|string'
    ]);

    if($validator->fails()){
        return $this->sendError('Validation Error.', 400, $validator->errors());
    }

    // Business logic langsung di controller
    if ($this->attemptLogin($request)) {
        $user = Auth::user();
        $user->generateToken();

        if(isset($request->onesignal_user_id)){
            $user->setOnesignalId($request->onesignal_user_id);
        }

        if($user->terminated){
            return $this->sendError('Sorry, This account has been terminated!' ,401);
        }

        $user->updateLastLogin();
        return $this->sendResponse($user->toArray());
    } else {
        return $this->sendError('Invalid username and password' ,401);
    }
}

Masalahnya? Kalau kamu mau pakai logic login yang sama di tempat lain (misalnya CLI command atau job queue), kamu harus duplicate code atau extract ke service nanti. Kenapa tidak dari awal saja dipisah? hehe~

Anti-Pattern: Permission Check Manual #

Contoh lain dari MaintenanceLogController:

// Cek permission di setiap method, manual
private function hasMaintenanceLogAccess()
{
    if (!$this->user || !$this->user->role) {
        return false;
    }
    return $this->user->role_id == 1 || $this->user->role_id == 6;
}

public function index(Request $request)
{
    if (!$this->hasMaintenanceLogAccess()) {
        return $this->sendError('You do not have permission', 403);
    }
    // ... logic
}

public function store(Request $request)
{
    if (!$this->hasMaintenanceLogAccess()) {
        return $this->sendError('You do not have permission', 403);
    }
    // ... logic
}

// Dan seterusnya di setiap method...

Ini contoh pattern yang seharusnya pakai Laravel Policy atau Middleware. Bayangkan kalau ada 10 method, kamu call fungsi yang sama 10 kali. DRY (Don’t Repeat Yourself)2 itu bukan cuma buzzword, itu survival strategy hahaha.

Best Practice: Thin Controller + Service Layer #

Di proyek yang sama, ada controller yang sudah pakai pattern yang benar. Contoh: GeminiAIController:

// app/Http/Controllers/GeminiAIController.php
// Pattern: Form Request + Service Injection + API Resource ✅

class GeminiAIController extends AuthenticatedController
{
    protected $geminiService;

    public function __construct(GeminiAIService $geminiService)
    {
        parent::__construct();
        $this->geminiService = $geminiService;  // Dependency Injection
    }

    public function analyzeDevice(GeminiAnalyzeDeviceRequest $request)  // Form Request
    {
        try {
            $device = $this->user->devices()
                ->where('device_sn', $request->input('device_id'))
                ->first();

            if (!$device) {
                return $this->sendError('Device not found', 404);
            }

            $deviceData = $this->prepareDeviceData($device, ...);
            $result = $this->geminiService->analyzeDeviceData($deviceData, ...);  // Delegasi ke service

            return $this->sendResponse(new GeminiDeviceAnalysisResource($result));  // Resource transformation
        } catch (\Exception $e) {
            return $this->sendError($e->getMessage(), 500);
        }
    }
}

Ini pattern yang harus ditiru:

  1. Form Request untuk validasi (bukan Validator::make() di controller)
  2. Service Injection untuk business logic (bukan logic di controller)
  3. API Resource untuk transformasi response (bukan toArray() langsung)
  4. Try-catch di top level saja

Implementasi di Go #

Di Go, service layer bukan opsi, itu keharusan. Karena Go tidak punya framework magic seperti Laravel, kamu harus explicit:

// internal/handler/auth_handler.go
type AuthHandler struct {
    authService service.AuthService
    validator   *validator.Validate
}

func NewAuthHandler(as service.AuthService, v *validator.Validate) *AuthHandler {
    return &AuthHandler{
        authService: as,
        validator:   v,
    }
}

// Handler = thin, hanya terima request, validasi, panggil service, return response
func (h *AuthHandler) Login(c *gin.Context) {
    var req dto.LoginRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        response.Error(c, http.StatusBadRequest, "Invalid request body")
        return
    }

    // Validasi struct
    if err := h.validator.Struct(req); err != nil {
        response.ValidationError(c, err)
        return
    }

    // Delegasi ke service
    result, err := h.authService.Login(c.Request.Context(), req)
    if err != nil {
        switch {
        case errors.Is(err, service.ErrInvalidCredentials):
            response.Error(c, http.StatusUnauthorized, "Invalid username or password")
        case errors.Is(err, service.ErrAccountTerminated):
            response.Error(c, http.StatusUnauthorized, "Account has been terminated")
        default:
            response.Error(c, http.StatusInternalServerError, "Internal server error")
        }
        return
    }

    response.Success(c, result)
}
// internal/service/auth_service.go
type AuthService interface {
    Login(ctx context.Context, req dto.LoginRequest) (*dto.LoginResponse, error)
}

type authService struct {
    userRepo repository.UserRepository
    tokenRepo repository.TokenRepository
}

func (s *authService) Login(ctx context.Context, req dto.LoginRequest) (*dto.LoginResponse, error) {
    user, err := s.userRepo.FindByEmail(ctx, req.Email)
    if err != nil {
        return nil, service.ErrInvalidCredentials
    }

    if err := bcrypt.CompareHashAndPassword(
        []byte(user.Password), []byte(req.Password),
    ); err != nil {
        return nil, service.ErrInvalidCredentials
    }

    if user.Terminated {
        return nil, service.ErrAccountTerminated
    }

    token, err := s.tokenRepo.Create(ctx, user.ID)
    if err != nil {
        return nil, err
    }

    return &dto.LoginResponse{
        User:  dto.NewUserResponse(user),
        Token: token,
    }, nil
}

Perbedaan utama dari PHP/Laravel: tidak ada inheritance, tidak ada magic method, tidak ada middleware constructor. Semuanya explicit lewat dependency injection. Awalnya memang terasa lebih banyak boilerplate—dan iya, memang lebih banyak—tapi trade-off-nya adalah setiap layer bisa di-test secara independen, dan flow data selalu terlihat jelas. Tidak ada yang tersembunyi di belakang layar hehe~

3. Validasi: Konsisten, Satu Cara, di Satu Tempat #

Apa yang Saya Temukan #

Di proyek ini ada tiga cara validasi yang dipakai secara bersamaan:

Cara 1: Manual Validator::make() (paling umum, 80%+ controller):

$validator = Validator::make($request->all(), [
    'email' => 'required|string',
    'password' => 'required|string'
]);
if($validator->fails()){
    return $this->sendError('Validation Error.', 400, $validator->errors());
}

Cara 2: Inline $request->validate():

$validated = $request->validate([
    'start_dt' => 'required|date',
    'end_dt' => 'required|date|after_or_equal:start_dt',
    'group_id' => 'nullable|integer',
]);

Cara 3: Form Request (paling sedikit, tapi paling bersih):

// app/Http/Requests/GeminiAnalyzeDeviceRequest.php
class GeminiAnalyzeDeviceRequest extends FormRequest
{
    public function rules()
    {
        return [
            'device_id' => 'required|string',
            'analysis_type' => 'nullable|in:general,performance,behavior',
            'date_range' => 'nullable|string',
            'limit' => 'nullable|integer|min:1|max:1000',
        ];
    }
}

Masalah dengan cara 1 dan 2? Kalau ada 5 endpoint yang membutuhkan validasi device_id, kamu nulis rule yang sama 5 kali. Dan kalau rule-nya berubah, kamu harus cari dan update di 5 tempat berbeda. Fun times hahaha.

Best Practice #

Gunakan Form Request secara konsisten. Alasannya:

  1. Reusable: satu form request bisa dipakai di banyak endpoint
  2. Testable: form request bisa di-test secara independen tanpa hit endpoint
  3. Readable: semua rule di satu tempat, tidak tersebar di controller
  4. Auto-documentation: kalau pakai tool seperti Swagger/L5-Swagger, form request otomatis ter-generate docs-nya

Implementasi di Go #

Di Go, validasi pakai struct tags:

// internal/dto/request.go
type LoginRequest struct {
    Email    string `json:"email" validate:"required,email"`
    Password string `json:"password" validate:"required,min=8"`
}

type AnalyzeDeviceRequest struct {
    DeviceID     string `json:"device_id" validate:"required"`
    AnalysisType string `json:"analysis_type" validate:"omitempty,oneof=general performance behavior"`
    DateRange    string `json:"date_range" validate:"omitempty"`
    Limit        int    `json:"limit" validate:"omitempty,min=1,max=1000"`
}

Keenangan: di Go, karena struct adalah first-class citizen, validasi otomatis ter-attach ke tipe data. Tidak mungkin ada “lho, validasi mana yang dipakai di endpoint ini?” karena struct-nya langsung visible di handler signature hehe~

4. Response Consistency: Satu Format, Semua Endpoint #

Apa yang Saya Temukan #

Proyek ini punya base controller yang lumayan bagus:

// app/Http/Controllers/Controller.php
class Controller extends BaseController
{
    public function sendResponse($result = "", $message = "OK")
    {
        return response()->json([
            'success' => true,
            'message' => $message,
            'data' => $result,
        ], 200);
    }

    public function sendError($error, $code = 400, $errorMessages = [])
    {
        $response = [
            'success' => false,
            'message' => $error,
        ];
        if(!empty($errorMessages)){
            $response['errors'] = $errorMessages;
        }
        return response()->json($response, $code);
    }
}

Bagus kan? Tapi… ada yang nyelip:

// loginOauth() pakai format beda!
return response()->json([
    'ResponseCode' => 0,           // ❌ beda format
    "ResponseString" => 'OK',      // ❌ beda key
    "data" => new UserOauthResource($user)
], 200);

// Sedangkan login() pakai format standard
return $this->sendResponse($user->toArray());  // ✅ konsisten

Bayangkan frontend developer yang harus handle dua format response berbeda untuk endpoint login yang pada dasarnya sama. Pasti mental dia “why tho?” hahaha.

Best Practice #

Satu format response, semua endpoint, tidak ada pengecualian. Kalau perlu versioning, pakai header atau URL prefix, bukan format response yang berbeda.

// Gunakan API Resource secara konsisten
return $this->sendResponse(new UserResource($user));

// Untuk collection dengan pagination
return $this->sendResponse([
    'devices' => DeviceResource::collection($devices),
    'meta' => new PaginationResource($devices),
]);

Implementasi di Go #

// internal/pkg/response/response.go
type Response struct {
    Success bool   `json:"success"`
    Message string `json:"message"`
    Data    any    `json:"data,omitempty"`
    Errors  any    `json:"errors,omitempty"`
}

func Success(c *gin.Context, data any) {
    c.JSON(http.StatusOK, Response{
        Success: true,
        Message: "OK",
        Data:    data,
    })
}

func Error(c *gin.Context, code int, message string) {
    c.JSON(code, Response{
        Success: false,
        Message: message,
    })
}

func ValidationError(c *gin.Context, err error) {
    c.JSON(http.StatusBadRequest, Response{
        Success: false,
        Message: "Validation Error",
        Errors:  formatValidationErrors(err),
    })
}

Dengan helper function ini, semua handler pasti mengembalikan format yang sama. Tidak ada lagi “loh, ini endpoint pakai ResponseCode atau success?” karena tinggal panggil response.Success() atau response.Error(). Done. Simple, predictable, happy frontend developer hehe~

5. Auth & Authorization: Guard, bukan Manual Check #

Apa yang Saya Temukan #

Proyek ini punya auth guard yang cukup matang:

// 5 guard untuk 5 domain berbeda
Route::group(['middleware' => ['auth:user-api']], function() { ... });
Route::group(['middleware' => ['auth:partner-api']], function() { ... });
Route::group(['middleware' => ['auth:open-api']], function() { ... });
Route::group(['middleware' => ['auth:open-api-limited']], function() { ... });
Route::group(['middleware' => ['auth:tms-api']], function() { ... });

Dan ada middleware khusus untuk device-level authorization:

// Device ownership check via middleware
Route::group(['middleware' => 'user_device_auth'], function() {
    Route::resource('devices', 'DeviceController');
    Route::get('/devices/{id}/last_location', 'DeviceController@lastLocation');
    // ...
});

Ini bagus. Tapi masalahnya ada di AuthenticatedController:

class AuthenticatedController extends Controller
{
    public function __construct()
    {
        $this->middleware(function ($request, $next) {
            // ❌ Manual token validation di constructor
            $this->user = Auth::guard('user-api')->user();
            $token = $request->bearerToken();
            $valid = Helper::validateToken($token);

            if(!$valid){
                return response()->json(['error' => 'Invalid Token'], 401);
            } elseif($this->user->terminated) {
                return response()->json(['message' => 'Sorry...'], 401);
            } else {
                return $next($request);
            }
        });
    }
}

Kenapa ini masalah? Karena token validation logic di-embed di constructor controller, bukan di dedicated middleware. Artinya:

Best Practice #

Pisahkan concern:

// app/Http/Middleware/ValidateApiToken.php
class ValidateApiToken
{
    public function handle($request, Closure $next)
    {
        $token = $request->bearerToken();
        if (!Helper::validateToken($token)) {
            return response()->json(['error' => 'Invalid Token'], 401);
        }
        return $next($request);
    }
}

// app/Http/Middleware/CheckNotTerminated.php
class CheckNotTerminated
{
    public function handle($request, Closure $next)
    {
        $user = Auth::guard('user-api')->user();
        if ($user->terminated) {
            return response()->json(['message' => 'Account terminated'], 401);
        }
        return $next($request);
    }
}

Dengan ini, middleware bisa di-compose dan di-test secara independen.

Implementasi di Go #

// internal/middleware/auth.go
func (m *AuthMiddleware) Guard(guardName string) gin.HandlerFunc {
    return func(c *gin.Context) {
        token := c.GetHeader("Authorization")
        if token == "" {
            response.Error(c, http.StatusUnauthorized, "Missing authorization token")
            c.Abort()
            return
        }

        claims, err := m.tokenService.ValidateToken(c, token)
        if err != nil {
            response.Error(c, http.StatusUnauthorized, "Invalid token")
            c.Abort()
            return
        }

        // Guard-specific checks
        switch guardName {
        case "user-api":
            if claims.Type != "user" {
                response.Error(c, http.StatusForbidden, "Invalid token type")
                c.Abort()
                return
            }
            user, err := m.userRepo.FindByID(c, claims.UserID)
            if err != nil || user.Terminated {
                response.Error(c, http.StatusUnauthorized, "Account terminated")
                c.Abort()
                return
            }
            c.Set("user", user)
        case "partner-api":
            // Partner-specific validation...
        }

        c.Next()
    }
}

// Penggunaan di router
userAPI := r.Group("/")
userAPI.Use(authMiddleware.Guard("user-api"))

Di Go, karena setiap middleware adalah fungsi yang bisa di-compose, kamu bisa mix and match sesuai kebutuhan tanpa inheritance chain yang rumit. Composition over inheritance3 bukan cuma jargon, di Go itu cara hidup hahaha.

6. Pecah Fat Controller jadi Handler Terpisah #

Masalah Besar #

DeviceController punya 40+ method. Mari bayangkan: kalau ada bug di fitur report, kamu harus scroll melewati 1000+ baris kode fitur lokasi, fitur perintah, fitur dashboard… baru nemu yang kamu cari. Developer experience-nya kurang lebih kayak cari jarum di tumpukan jerami, tapi jeraminya code hahaha.

Solusi: Satu Domain = Satu Handler #

Di Go, ini terjadi secara natural karena Go tidak punya class inheritance. Setiap handler adalah struct terpisah:

// internal/handler/device_handler.go - CRUD only
type DeviceHandler struct {
    deviceService service.DeviceService
}

func (h *DeviceHandler) Index(c *gin.Context)   { ... }
func (h *DeviceHandler) Show(c *gin.Context)    { ... }
func (h *DeviceHandler) Store(c *gin.Context)   { ... }
func (h *DeviceHandler) Update(c *gin.Context)  { ... }
func (h *DeviceHandler) Destroy(c *gin.Context) { ... }

// internal/handler/device_report_handler.go - Reports only
type DeviceReportHandler struct {
    reportService service.DeviceReportService
}

func (h *DeviceReportHandler) GetSummaryRoute(c *gin.Context) { ... }
func (h *DeviceReportHandler) GetStopReport(c *gin.Context)   { ... }
func (h *DeviceReportHandler) GetFuelReport(c *gin.Context)   { ... }

// internal/handler/device_command_handler.go - Commands only
type DeviceCommandHandler struct {
    commandService service.DeviceCommandService
}

func (h *DeviceCommandHandler) CutOffEngine(c *gin.Context) { ... }
func (h *DeviceCommandHandler) TurnOnEngine(c *gin.Context) { ... }

Tips migrasi: Sebelum konversi ke Go, pecah dulu controller PHP-mu menjadi handler yang lebih kecil. Ini membuat proses migrasi jauh lebih mudah karena setiap handler sudah punya scope yang jelas. Clean before you move, bukan move then clean hehe~

Ringkasan: Checklist Best Practice #

Berikut checklist yang bisa langsung kamu pakai:

Aspek ❌ Jangan ✅ Lakukan
Validasi Validator::make() di controller Form Request / struct tags
Business Logic Langsung di controller Pisah ke service layer
Response Format beda-beda per endpoint Satu format konsisten
Permission Manual check di setiap method Policy / Middleware
Controller Satu controller 40+ method Pecah per domain
Route naming Campur snake_case/kebab-case Konsisten satu style
Rate limiting Hanya di route yang “berbahaya” Semua endpoint yang expensive
Auth Logic di constructor controller Dedicated middleware

Penutup #

Best practice itu bukan soal “cara yang paling sempurna”. Tapi soal konsistensi. Lebih baik konsisten pakai pattern yang “cukup bagus” di seluruh codebase, daripada mix and match pattern “sempurna” yang bikin developer baru bingung harus ikut yang mana.

Dan kalau kamu sekarang lagi menatap codebase yang punya 40 method di satu controller, tiga cara validasi berbeda, dan response format yang tidak konsisten — tenang. Banyak yang sudah lewat fase itu. Termasuk saya. Bedanya, sekarang ada catatan ini hehe~

Kalau kamu sedang migrate dari PHP ke Go, atau mau mulai proyek baru dengan arsitektur yang lebih bersih, hubungi saya. Atau lihat layanan yang bisa saya bantu.

Terima kasih buat yang sudah tersasar ke sini dan membaca sampai akhir. Semoga bermanfaat!

Sekian. Salam.


  1. Angka ini termasuk public route, authenticated route, partner route, dan berbagai integrasi. Yah, cukup banyak untuk di-audit satu-satu hahaha. ↩︎

  2. DRY (Don’t Repeat Yourself) — prinsip yang sederhana tapi sering dilupakan saat deadline mendekat. Guilty as charged hehe~ ↩︎

  3. Composition over inheritance — prinsip di mana lebih baik menyusun behavior dari komponen-komponen kecil daripada membuat inheritance tree yang dalam. Di Go, ini bukan pilihan, tapi default↩︎