Lesson 77 of 77 advanced

API Integration Without AI: Proving Your Fundamentals in Interviews

The LinkedIn Post That Exposed 4-5 Year 'Senior' Devs Who Can't Call a REST API Without ChatGPT

Open interactive version (quiz + challenge)

Real-world analogy

Imagine a chef who has cooked with a recipe app for 5 years but panics when the phone dies. They can't dice an onion, don't know oven temperatures, and ask 'Alexa, how do I boil water?' That's what interviewers see when senior Flutter devs can't integrate a simple API without AI. The recipe app (AI) made them faster, but they never learned to cook. A real chef knows knife skills, heat control, and timing by heart — AI should make a great chef faster, not replace knowing how food works.

What is it?

API integration without AI is the fundamental skill of connecting a Flutter app to a REST API using only your knowledge of HTTP, Dart, and Flutter — without relying on AI tools to generate the code. It means you understand every line: how HTTP requests work, how JSON maps to Dart objects, how errors propagate, and how to secure credentials. The viral LinkedIn post exposed that many 'senior' Flutter devs with 4-5 years experience cannot perform this basic task without AI assistance, and worse, some share API keys and tokens with AI tools. This lesson makes you bulletproof against that criticism by drilling the fundamentals until they are second nature.

Real-world relevance

A fintech startup interviews a Flutter developer with 5 years experience. The interviewer gives them a REST API for fetching transaction history and says 'build a screen that shows the user their transactions — you have 30 minutes, no internet except the API.' The candidate stares at the screen. They open Chrome to go to ChatGPT — blocked. They try to remember the http package syntax — blank. They can't write fromJson without json_serializable. They don't know how to handle a 401. They get rejected. The next candidate — with only 2 years experience — writes the model, service, error handling, and UI in 25 minutes. They get the offer at a higher salary. The difference wasn't talent. It was that one practiced fundamentals while the other outsourced thinking to AI.

Key points

Code example

// === COMPLETE API INTEGRATION FROM SCRATCH ===
// No freezed, no code gen, no AI — pure fundamentals

// ---- 1. CONFIGURATION (NEVER hardcode secrets) ----
// Use: flutter run --dart-define=API_BASE_URL=https://api.example.com
// Use: flutter run --dart-define=API_KEY=your_key_here

class ApiConfig {
  // Read from environment at compile time
  static const String baseUrl = String.fromEnvironment(
    'API_BASE_URL',
    defaultValue: 'https://jsonplaceholder.typicode.com',
  );

  // NEVER do this:
  // static const apiKey = 'sk-1234567890abcdef'; // CAREER ENDING
  // NEVER paste this into ChatGPT/Copilot either!
}

// ---- 2. MODEL CLASS (Manual fromJson/toJson) ----
class User {
  final int id;
  final String name;
  final String email;
  final Address? address; // Nullable nested object

  const User({
    required this.id,
    required this.name,
    required this.email,
    this.address,
  });

  // Factory constructor — know this by heart
  factory User.fromJson(Map<String, dynamic> json) {
    return User(
      id: json['id'] as int,
      name: json['name'] as String,
      email: json['email'] as String,
      address: json['address'] != null
          ? Address.fromJson(json['address'] as Map<String, dynamic>)
          : null,
    );
  }

  Map<String, dynamic> toJson() {
    return {
      'id': id,
      'name': name,
      'email': email,
      'address': address?.toJson(),
    };
  }
}

class Address {
  final String street;
  final String city;

  const Address({required this.street, required this.city});

  factory Address.fromJson(Map<String, dynamic> json) {
    return Address(
      street: json['street'] as String,
      city: json['city'] as String,
    );
  }

  Map<String, dynamic> toJson() => {'street': street, 'city': city};
}

// ---- 3. CUSTOM EXCEPTIONS (Not just generic catch-all) ----
sealed class ApiException implements Exception {
  final String message;
  final int? statusCode;
  const ApiException(this.message, [this.statusCode]);
}

class NetworkException extends ApiException {
  const NetworkException() : super('No internet connection');
}

class TimeoutException extends ApiException {
  const TimeoutException() : super('Request timed out');
}

class UnauthorizedException extends ApiException {
  const UnauthorizedException() : super('Session expired', 401);
}

class ForbiddenException extends ApiException {
  const ForbiddenException() : super('Access denied', 403);
}

class NotFoundException extends ApiException {
  const NotFoundException(String resource)
      : super('$resource not found', 404);
}

class ServerException extends ApiException {
  const ServerException() : super('Server error. Try later.', 500);
}

class ParseException extends ApiException {
  const ParseException(String detail)
      : super('Failed to parse response: $detail');
}

// ---- 4. API SERVICE (with proper error handling) ----
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;

class ApiService {
  final http.Client _client;
  final String _baseUrl;
  String? _authToken;

  ApiService({
    http.Client? client,
    String? baseUrl,
  })  : _client = client ?? http.Client(),
        _baseUrl = baseUrl ?? ApiConfig.baseUrl;

  void setAuthToken(String token) => _authToken = token;

  // Core request method — handles ALL error cases
  Future<dynamic> _request(
    String method,
    String path, {
    Map<String, dynamic>? body,
    Map<String, String>? queryParams,
  }) async {
    final uri = Uri.parse('$_baseUrl$path').replace(
      queryParameters: queryParams,
    );

    final headers = <String, String>{
      'Content-Type': 'application/json',
      'Accept': 'application/json',
      if (_authToken != null) 'Authorization': 'Bearer $_authToken',
    };

    try {
      late http.Response response;

      switch (method) {
        case 'GET':
          response = await _client
              .get(uri, headers: headers)
              .timeout(const Duration(seconds: 15));
        case 'POST':
          response = await _client
              .post(uri, headers: headers, body: jsonEncode(body))
              .timeout(const Duration(seconds: 15));
        case 'PUT':
          response = await _client
              .put(uri, headers: headers, body: jsonEncode(body))
              .timeout(const Duration(seconds: 15));
        case 'DELETE':
          response = await _client
              .delete(uri, headers: headers)
              .timeout(const Duration(seconds: 15));
        default:
          throw ArgumentError('Unsupported HTTP method: $method');
      }

      return _handleResponse(response);
    } on SocketException {
      throw const NetworkException();
    } on TimeoutException {
      throw const TimeoutException();
    } on ApiException {
      rethrow; // Don't wrap our own exceptions
    } catch (e) {
      throw ParseException(e.toString());
    }
  }

  dynamic _handleResponse(http.Response response) {
    switch (response.statusCode) {
      case 200 || 201:
        try {
          return jsonDecode(response.body);
        } on FormatException catch (e) {
          throw ParseException(e.message);
        }
      case 204:
        return null;
      case 401:
        throw const UnauthorizedException();
      case 403:
        throw const ForbiddenException();
      case 404:
        throw const NotFoundException('Resource');
      case >= 500:
        throw const ServerException();
      default:
        throw ApiException(
          'Request failed: ${response.statusCode}',
          response.statusCode,
        );
    }
  }

  // Public API methods — clean and simple
  Future<List<User>> getUsers({int page = 1, int limit = 20}) async {
    final json = await _request('GET', '/users', queryParams: {
      '_page': page.toString(),
      '_limit': limit.toString(),
    });
    return (json as List).map((e) => User.fromJson(e)).toList();
  }

  Future<User> getUser(int id) async {
    final json = await _request('GET', '/users/$id');
    return User.fromJson(json);
  }

  Future<User> createUser(User user) async {
    final json = await _request('POST', '/users', body: user.toJson());
    return User.fromJson(json);
  }

  void dispose() => _client.close();
}

// ---- 5. DIO INTERCEPTOR (Token Refresh Pattern) ----
// For production apps using dio package:
/*
class AuthInterceptor extends Interceptor {
  final Dio dio;
  final AuthRepository authRepo;

  AuthInterceptor(this.dio, this.authRepo);

  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
    final token = authRepo.accessToken;
    if (token != null) {
      options.headers['Authorization'] = 'Bearer $token';
    }
    handler.next(options);
  }

  @override
  void onError(DioException err, ErrorInterceptorHandler handler) async {
    if (err.response?.statusCode == 401) {
      try {
        // Refresh the token
        final newToken = await authRepo.refreshToken();
        // Retry the original request with new token
        final options = err.requestOptions;
        options.headers['Authorization'] = 'Bearer $newToken';
        final response = await dio.fetch(options);
        handler.resolve(response);
        return;
      } catch (e) {
        // Refresh failed — force logout
        authRepo.logout();
      }
    }
    handler.next(err);
  }
}
*/

// ---- 6. USAGE IN UI ----
// Using FutureBuilder (simple) or state management (production)
/*
class UsersScreen extends StatefulWidget {
  @override
  State<UsersScreen> createState() => _UsersScreenState();
}

class _UsersScreenState extends State<UsersScreen> {
  late final ApiService _api;
  late Future<List<User>> _usersFuture;

  @override
  void initState() {
    super.initState();
    _api = ApiService();
    _usersFuture = _api.getUsers();
  }

  @override
  void dispose() {
    _api.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Users')),
      body: FutureBuilder<List<User>>(
        future: _usersFuture,
        builder: (context, snapshot) {
          if (snapshot.connectionState == ConnectionState.waiting) {
            return const Center(child: CircularProgressIndicator());
          }
          if (snapshot.hasError) {
            final error = snapshot.error;
            return Center(
              child: Column(
                mainAxisSize: MainAxisSize.min,
                children: [
                  Text(_getErrorMessage(error)),
                  ElevatedButton(
                    onPressed: () {
                      setState(() => _usersFuture = _api.getUsers());
                    },
                    child: const Text('Retry'),
                  ),
                ],
              ),
            );
          }
          final users = snapshot.data!;
          return ListView.builder(
            itemCount: users.length,
            itemBuilder: (context, index) {
              final user = users[index];
              return ListTile(
                title: Text(user.name),
                subtitle: Text(user.email),
              );
            },
          );
        },
      ),
    );
  }

  String _getErrorMessage(Object? error) {
    if (error is NetworkException) return 'No internet. Check connection.';
    if (error is TimeoutException) return 'Server slow. Try again.';
    if (error is UnauthorizedException) return 'Please log in again.';
    if (error is ServerException) return 'Server error. Try later.';
    return 'Something went wrong.';
  }
}
*/

Line-by-line walkthrough

  1. 1. ApiConfig uses String.fromEnvironment to read secrets at compile time via --dart-define flags. This means API keys NEVER appear in source code — they're injected during the build. The defaultValue is only for development with public APIs.
  2. 2. The User model class has typed final fields and a required const constructor. Every field has an explicit type. The address field is nullable (Address?) because the API might not always include it.
  3. 3. User.fromJson is a factory constructor that takes Map — the standard type that jsonDecode returns. Each field is explicitly cast (json['id'] as int) which gives clear error messages if the API returns unexpected types instead of a vague runtime crash.
  4. 4. The nested Address object is parsed by checking for null first, then calling Address.fromJson. This is the pattern for every nested object — always null-check before parsing to prevent null reference errors on optional fields.
  5. 5. toJson returns a Map with the ?. operator on nullable address — if address is null, the map value is null rather than crashing. This mirrors the fromJson null handling.
  6. 6. The sealed class ApiException hierarchy creates specific exception types for each failure mode. Using sealed means the compiler can check you've handled all cases in a switch statement — no missed error scenarios.
  7. 7. ApiService takes an optional http.Client parameter — this is dependency injection for testing. In tests, you pass a MockClient. In production, it creates a real client. This is a pattern interviewers love to see.
  8. 8. The _request method builds the URI, constructs headers with conditional auth token spread, and uses a switch expression on the HTTP method. The .timeout(Duration(seconds: 15)) ensures requests don't hang forever.
  9. 9. _handleResponse uses pattern matching on status codes. The key insight: check the status code BEFORE trying to parse the body. A 401 response might have HTML error page as body — parsing it as JSON would throw a confusing FormatException instead of the real 'unauthorized' error.
  10. 10. The SocketException catch maps to NetworkException and TimeoutException maps to our custom TimeoutException. The 'on ApiException rethrow' line prevents wrapping our own exceptions — a subtle but important detail that prevents error message corruption.
  11. 11. The public API methods (getUsers, getUser, createUser) are clean one-liners that call _request and map the response to typed objects. Pagination parameters are passed as query params with toString() conversion since query params must be strings.
  12. 12. The AuthInterceptor (dio pattern) intercepts 401 errors, refreshes the token, and retries the original request transparently. This means API service classes never need to handle auth — it's a cross-cutting concern handled in one place.

Spot the bug

class Post {
  final int id;
  final String title;
  final String body;

  Post({required this.id, required this.title, required this.body});

  factory Post.fromJson(Map<String, dynamic> json) {
    return Post(
      id: json['id'],
      title: json['title'],
      body: json['body'],
    );
  }
}

class ApiService {
  static const _baseUrl = 'https://api.example.com';
  static const _apiKey = 'sk_live_a1b2c3d4e5f6g7h8i9j0';

  Future<List<Post>> getPosts() async {
    final response = await http.get(
      Uri.parse('$_baseUrl/posts'),
      headers: {'X-API-Key': _apiKey},
    );

    final List data = jsonDecode(response.body);
    return data.map((json) => Post.fromJson(json)).toList();
  }
}
Need a hint?
There are FOUR critical bugs here — two are security issues that would get you instantly rejected in an interview, one is a type safety issue that causes runtime crashes, and one is a missing error handling pattern. Think about: secrets management, type casting, response validation, and what AI-dependent developers commonly miss.
Show answer
Bug 1 (SECURITY — Career Ending): The API key is hardcoded in source code (_apiKey = 'sk_live_...'). This will be committed to version control, visible in the compiled app, and if this class were pasted into an AI tool for debugging, the live API key goes to a third-party server. Fix: Use String.fromEnvironment or flutter_dotenv. Bug 2 (TYPE SAFETY): fromJson doesn't cast types — json['id'] returns dynamic, not int. This works in testing but crashes in production when the API returns a number as String (common with some backends). Fix: Use explicit casts like json['id'] as int. Bug 3 (NO ERROR HANDLING): getPosts never checks response.statusCode. If the API returns a 401, 404, or 500, the code tries to jsonDecode an error response (possibly HTML), throwing a confusing FormatException instead of a meaningful error. Fix: Check statusCode before parsing body. Bug 4 (NO TIMEOUT): The request has no timeout — if the server hangs, the app freezes indefinitely with a loading spinner. Fix: Add .timeout(const Duration(seconds: 15)) to the HTTP call.

Explain like I'm 5

Imagine you learned to ride a bike with training wheels (AI). You rode everywhere — to school, to the park, to your friend's house. You thought you were great at biking! Then one day someone takes the training wheels off and says 'ride to the store.' You fall over immediately. That's what happens when developers use AI to write all their code and never learn how it actually works. The bike (API integration) isn't hard — you just need to practice without training wheels until you can balance on your own. THEN you can add a motor (AI) to go faster. But the motor only helps if you already know how to steer and brake.

Fun fact

A 2024 Stack Overflow survey found that 76% of developers use AI tools, but a separate study by Karat (a technical interviewing company) found that candidates who relied heavily on AI tools during preparation performed 40% worse in live coding interviews than those who practiced manually. The reason? AI-assisted developers could recognize correct code but couldn't produce it from scratch — a phenomenon researchers call 'recognition-production gap.' Even more alarming: GitHub's own research showed that code generated by Copilot contained security vulnerabilities 40% of the time, including hardcoded credentials. The Flutter dev community took notice when a viral LinkedIn post about senior devs failing basic API integration got 171 likes and hundreds of comments, with CTOs and hiring leads confirming they see this daily. One commenter said: 'I interviewed a dev who couldn't explain what a 404 status code means, but their GitHub was full of AI-generated code with perfect commit messages.'

Hands-on challenge

Build a complete API integration from scratch in 30 minutes with NO AI assistance, NO internet search, and NO code generation. Use https://jsonplaceholder.typicode.com/posts as your endpoint. Requirements: (1) Create a Post model with manual fromJson/toJson including userId (int), id (int), title (String), body (String). (2) Create an ApiService class with GET all posts, GET single post by id, POST create new post, DELETE post. (3) Handle at minimum: no internet, timeout, 404, 500, and JSON parse errors with specific messages for each. (4) No hardcoded URLs in the service methods — use a base URL config. (5) Write a simple UI screen that fetches posts and displays them, with a retry button on error. Time yourself. If you can't finish in 30 minutes, practice daily until you can. This is the exact test companies are giving in interviews.

More resources

Open interactive version (quiz + challenge) ← Back to course: Flutter Interview Mastery