🚀 Salesforce Platform Developer I (PDI)への道：Apex・SOQLマスターガイド 💻

こんにちは😊
[株式会社プロドウガ](https://qiita.com/organizations/prodouga-com)の@YushiYamamotoです！
[らくらくサイト](https://rakuraku-site.prodouga.com/)の開発・運営を担当しながら、React.js・Next.js専門のフリーランスエンジニアとしても活動しています❗️

Salesforceのエコシステムは日々拡大し、多くの企業がこのプラットフォームを採用しています。それに伴い、Salesforce開発者の需要も高まっています。特に「Platform Developer I (PDI)」認定資格は、Salesforceプラットフォーム上での開発スキルを証明する重要な資格として注目されています。

今回は、Salesforce Platform Developer I資格の取得を目指す方や、Salesforceでの開発に興味がある方に向けて、Apex、SOQL、開発原則の基本から応用までを解説します。未経験からでもステップバイステップで理解できるよう、具体的なコード例も交えながら説明していきます！

## 📋 Salesforce Platform Developer I (PDI)とは？

Salesforce Platform Developer I (PDI)は、Salesforceプラットフォーム上でのカスタム開発能力を証明する基本的な開発者資格です。この資格は、Salesforceの独自プログラミング言語であるApexとデータベースクエリ言語であるSOQLの知識、そしてSalesforceプラットフォームの基本的な開発原則の理解を評価します。

**認定資格が証明するスキル**：

- Apexクラス、トリガー、コントローラーの開発
- SOQLとSOSLを使用したデータ操作
- ビジネスロジックの実装とテスト
- デバッグとトラブルシューティング
- Lightning コンポーネントの基本
- Salesforceプラットフォームの制限とベストプラクティス

```mermaid
graph LR
    A[Salesforce Platform Developer I] --> B[Apex開発]
    A --> C[データモデリング]
    A --> D[ユーザーインターフェース]
    A --> E[テストとデバッグ]
    A --> F[開発ライフサイクル]
    B --> G[Apexクラス]
    B --> H[Apexトリガー]
    B --> I[バッチApex]
    C --> J[オブジェクト関係]
    C --> K[SOQL]
    C --> L[SOSL]
    D --> M[Visualforceの基本]
    D --> N[Lightningの基本]
    E --> O[単体テスト]
    E --> P[デバッグログ]
    F --> Q[バージョン管理]
    F --> R[デプロイメント]
```


## 📚 試験の概要と準備

Platform Developer I試験は、Salesforceプラットフォームでのカスタム開発に関する基本的な理解を評価するものです。

**試験の基本情報**：

- 問題数: 60問（+5問の非採点問題）
- 試験時間: 105分
- 形式: 多肢選択式
- 合格ライン: 65%（39問以上の正解）
- 受験料: \$200 USD
- 言語: 英語、日本語含む複数言語で受験可能

**試験の主な出題分野**：

1. Salesforceの基礎知識（7%）
2. データモデリングとマネジメント（13%）
3. ロジックとプロセス自動化（33%）
4. ユーザーインターフェース（7%）
5. テスト（15%）
6. パフォーマンス（12%）
7. インテグレーション（9%）
8. デバッグとデプロイメントツール（4%）

:::note info
2025年時点の情報です。最新の試験情報は常にSalesforceの公式サイトで確認することをお勧めします。
:::

## 💪 Apexプログラミングの基礎

Apexは、Salesforceプラットフォーム専用のプログラミング言語です。JavaやC\#に似た構文を持ち、強力な統合データベースコマンドと共に使用できます。

### Apexの特徴

- **Javaに似た構文**: オブジェクト指向で、クラス、インターフェース、継承などの概念をサポート
- **データベース統合**: SOQLとSOSLを直接コード内で使用可能
- **トランザクション管理**: データの整合性を自動的に保護
- **ガバナー制限**: マルチテナントアーキテクチャを保護するための実行制限があり
- **トリガー機能**: データベースイベントに応じて自動実行される処理を定義可能


### 基本的なApexクラスの例

```apex
public class AccountHelper {
    // パブリックメソッド - 他のクラスからアクセス可能
    public static void updateAccountRating(Id accountId, String newRating) {
        // SOQLを使ってアカウントを取得
        Account acc = [SELECT Id, Name, Rating FROM Account WHERE Id = :accountId LIMIT 1];
        
        // 値を更新
        acc.Rating = newRating;
        
        // データベースに保存
        try {
            update acc;
            System.debug('Account updated successfully: ' + acc.Name);
        } catch (Exception e) {
            System.debug('Error updating account: ' + e.getMessage());
        }
    }
    
    // プライベートメソッド - このクラス内部でのみアクセス可能
    private static void logAccountActivity(Account acc) {
        System.debug('Account activity logged for: ' + acc.Name);
    }
}
```


### Apexトリガーの基本

トリガーは、データベースのレコードが挿入、更新、削除される前後に自動的に実行されるApexコードです。

<details><summary>取引先(Account)トリガーの例</summary>

```apex
trigger AccountTrigger on Account (before insert, before update, after insert, after update) {
    // トリガーコンテキスト変数の使用例
    if (Trigger.isBefore) {
        // 挿入前または更新前の処理
        if (Trigger.isInsert) {
            // 挿入前の処理
            for (Account acc : Trigger.new) {
                // デフォルト値の設定
                if (acc.Industry == null) {
                    acc.Industry = 'その他';
                }
                
                // 会社名が設定されていない場合、エラーを追加
                if (String.isBlank(acc.Name)) {
                    acc.addError('会社名は必須です');
                }
            }
        } else if (Trigger.isUpdate) {
            // 更新前の処理
            for (Account acc : Trigger.new) {
                // 前の値と新しい値を比較
                Account oldAcc = Trigger.oldMap.get(acc.Id);
                
                // 業種が変更された場合、説明フィールドを更新
                if (acc.Industry != oldAcc.Industry) {
                    acc.Description = '業種が ' + oldAcc.Industry + ' から ' + 
                                     acc.Industry + ' に変更されました。(' + 
                                     System.today() + ')';
                }
            }
        }
    } else if (Trigger.isAfter) {
        // 挿入後または更新後の処理
        if (Trigger.isInsert) {
            // 挿入後の処理 - 関連レコードの作成など
            List<Contact> defaultContacts = new List<Contact>();
            
            for (Account acc : Trigger.new) {
                // 各アカウントにデフォルトの取引先責任者を作成
                Contact defaultContact = new Contact(
                    AccountId = acc.Id,
                    LastName = acc.Name + ' 担当者',
                    Email = 'contact@' + acc.Name.toLowerCase().replaceAll(' ', '') + '.example.com'
                );
                defaultContacts.add(defaultContact);
            }
            
            if (!defaultContacts.isEmpty()) {
                insert defaultContacts;
            }
        } else if (Trigger.isUpdate) {
            // 更新後の処理 - 関連レコードの更新など
            Set<Id> accountIds = Trigger.newMap.keySet();
            
            // 関連する商談のステージを更新
            List<Opportunity> relatedOpps = [
                SELECT Id, StageName, AccountId 
                FROM Opportunity 
                WHERE AccountId IN :accountIds
                AND IsClosed = false
            ];
            
            // ビジネスロジックの適用
            for (Opportunity opp : relatedOpps) {
                Account updatedAcc = Trigger.newMap.get(opp.AccountId);
                // ここで必要な処理を実行
            }
        }
    }
}
```

</details>
:::note warn
トリガーはバルク操作（一度に複数のレコードを処理）を想定して設計する必要があります。一つのトリガーは複数のレコードに対して一度だけ呼び出されるため、効率的なコードが求められます。
:::

### Apexのガバナー制限

Salesforceは共有環境（マルチテナント）で動作するため、リソースの公平な配分を確保するためにガバナー制限が設けられています：

- 1トランザクションあたりのSOQLクエリ数: 100件
- 1トランザクションあたりのDMLステートメント: 150件
- 1トランザクションあたりのCPUタイムリミット: 10,000ミリ秒
- ヒープサイズの制限: 6MB
- 同期的に処理できるレコード数: 最大2,000件

```mermaid
graph TD
    A[Apexコード実行] --> B{ガバナー制限}
    B --> C[SOQL制限]
    B --> D[DML制限]
    B --> E[CPU制限]
    B --> F[ヒープサイズ制限]
    C --> G[超過した場合]
    D --> G
    E --> G
    F --> G
    G --> H[実行時例外が発生]
```


## 🔍 SOQLの基本と活用法

SOQL（Salesforce Object Query Language）は、Salesforceのデータベースからデータを取得するためのクエリ言語です。SQLに似ていますが、Salesforce特有の機能があります。

### SOQLの基本構文

```sql
// 基本的なSOQLクエリ
SELECT field1, field2, ... FROM ObjectName 
WHERE condition 
ORDER BY field1 [ASC|DESC] 
LIMIT number_of_records
```


### SOQLの基本的な使用例

```apex
// 単純なSOQLクエリ - 取引先から名前と業種を取得
List<Account> accounts = [SELECT Name, Industry FROM Account WHERE Industry = 'テクノロジー' LIMIT 10];

// 取得したデータの処理
for (Account acc : accounts) {
    System.debug('Account Name: ' + acc.Name + ', Industry: ' + acc.Industry);
}
```


### リレーションを利用したSOQLクエリ

```apex
// 親子関係のクエリ - 取引先と関連する取引先責任者を取得
List<Account> accountsWithContacts = [
    SELECT 
        Name, 
        Industry,
        (SELECT FirstName, LastName, Email FROM Contacts)
    FROM Account
    WHERE Industry = 'テクノロジー'
    LIMIT 5
];

// 取得したデータの処理
for (Account acc : accountsWithContacts) {
    System.debug('Account: ' + acc.Name);
    
    // 子レコード（取引先責任者）を処理
    for (Contact con : acc.Contacts) {
        System.debug('-- Contact: ' + con.FirstName + ' ' + con.LastName);
    }
}
```


### 動的SOQLの活用


<details><summary>動的SOQLの例</summary>


```apex
public class DynamicQueryBuilder {
    // 動的にフィールドとフィルタを指定してSOQLを構築するメソッド
    public static List<SObject> queryRecords(
        String objectName, 
        List<String> fields, 
        Map<String, Object> filters, 
        Integer recordLimit
    ) {
        // SELECT句の構築
        String query = 'SELECT ';
        if (fields != null && !fields.isEmpty()) {
            query += String.join(fields, ', ');
        } else {
            query += 'Id'; // 最低限Idフィールドは取得
        }
        
        // FROM句
        query += ' FROM ' + objectName;
        
        // WHERE句の構築（フィルタがあれば）
        if (filters != null && !filters.isEmpty()) {
            query += ' WHERE ';
            List<String> filterConditions = new List<String>();
            
            for (String field : filters.keySet()) {
                Object value = filters.get(field);
                if (value instanceof String) {
                    // 文字列の場合はシングルクォートで囲む
                    filterConditions.add(field + ' = \'' + String.escapeSingleQuotes((String)value) + '\'');
                } else if (value instanceof List<Object>) {
                    // リストの場合はIN句を使用
                    List<Object> valueList = (List<Object>)value;
                    String inClause = '(';
                    for (Integer i = 0; i < valueList.size(); i++) {
                        if (valueList[i] instanceof String) {
                            inClause += '\'' + String.escapeSingleQuotes((String)valueList[i]) + '\'';
                        } else {
                            inClause += String.valueOf(valueList[i]);
                        }
                        
                        if (i < valueList.size() - 1) {
                            inClause += ', ';
                        }
                    }
                    inClause += ')';
                    filterConditions.add(field + ' IN ' + inClause);
                } else {
                    // 数値、真偽値などその他の型
                    filterConditions.add(field + ' = ' + String.valueOf(value));
                }
            }
            
            query += String.join(filterConditions, ' AND ');
        }
        
        // LIMIT句
        if (recordLimit != null && recordLimit > 0) {
            query += ' LIMIT ' + recordLimit;
        }
        
        System.debug('Executing query: ' + query);
        
        // 動的SOQLの実行
        return Database.query(query);
    }
}

// 使用例
List<String> accountFields = new List<String>{'Id', 'Name', 'Industry', 'AnnualRevenue'};
Map<String, Object> filters = new Map<String, Object>{
    'Industry' => 'テクノロジー',
    'AnnualRevenue' => 1000000
};

List<Account> results = (List<Account>)DynamicQueryBuilder.queryRecords('Account', accountFields, filters, 10);
```

</details>

:::note info
動的SOQLはとても柔軟ですが、SOQL Injectionというセキュリティリスクがあります。ユーザー入力をそのままクエリに使用せず、必ず`String.escapeSingleQuotes()`などでサニタイズしましょう。
:::

## 🏗️ Salesforce開発の基本原則

Salesforceでの開発には、クラウドプラットフォームならではの基本原則があります。これらの原則を理解し守ることで、拡張性が高く、効率的なアプリケーションを構築できます。

### 1. バルク処理の最適化

Salesforceでは、複数のレコードをまとめて処理するバルク処理を意識した実装が重要です。

#### 悪い例（バルク処理に対応していない）:

```apex
// 悪い例：ループ内でクエリやDML操作を実行
for (Contact contact : contacts) {
    // ループ内でクエリを実行（ガバナー制限に抵触しやすい）
    Account acc = [SELECT Id, Name FROM Account WHERE Id = :contact.AccountId];
    
    // ループ内でDML操作
    update acc;
}
```


#### 良い例（バルク処理に対応）:

```apex
// 良い例：バルク処理を意識した実装
// 必要なAccountIdを収集
Set<Id> accountIds = new Set<Id>();
for (Contact contact : contacts) {
    accountIds.add(contact.AccountId);
}

// クエリは1回だけ実行
Map<Id, Account> accountMap = new Map<Id, Account>([
    SELECT Id, Name FROM Account WHERE Id IN :accountIds
]);

// 更新が必要なアカウントを収集
List<Account> accountsToUpdate = new List<Account>();
for (Contact contact : contacts) {
    if (accountMap.containsKey(contact.AccountId)) {
        Account acc = accountMap.get(contact.AccountId);
        // 必要な処理を行う
        accountsToUpdate.add(acc);
    }
}

// DML操作は1回だけ実行
if (!accountsToUpdate.isEmpty()) {
    update accountsToUpdate;
}
```


### 2. トリガーベストプラクティス

トリガーは効果的なデータ検証と自動化を提供しますが、適切に設計しないとメンテナンスが困難になります。

**トリガーのベストプラクティス**:

1. **1オブジェクトにつき1トリガー**: 複数のトリガーを作成すると実行順序を制御できなくなる
2. **ヘルパークラスの使用**: トリガー内のロジックは別のクラスに移動させる
3. **再帰防止**: トリガーが自身の更新によって再度実行されるのを防ぐ
4. **バルク処理**: 複数レコードの同時処理を想定して設計する
<details><summary>ベストプラクティスに沿ったトリガー実装例</summary>

```apex
// AccountTrigger.trigger
trigger AccountTrigger on Account (before insert, before update, after insert, after update) {
    // トリガーハンドラーにロジックを委託
    AccountTriggerHandler handler = new AccountTriggerHandler();
    
    // 実行コンテキストに応じて適切なメソッドを呼び出す
    if (Trigger.isBefore) {
        if (Trigger.isInsert) {
            handler.onBeforeInsert(Trigger.new);
        } else if (Trigger.isUpdate) {
            handler.onBeforeUpdate(Trigger.new, Trigger.oldMap);
        }
    } else if (Trigger.isAfter) {
        if (Trigger.isInsert) {
            handler.onAfterInsert(Trigger.new);
        } else if (Trigger.isUpdate) {
            handler.onAfterUpdate(Trigger.new, Trigger.oldMap);
        }
    }
}

// AccountTriggerHandler.cls
public class AccountTriggerHandler {
    // 再帰防止用のフラグ
    private static Boolean isExecuting = false;
    
    // Before Insertイベントの処理
    public void onBeforeInsert(List<Account> newAccounts) {
        // 再帰チェック
        if (isExecuting) return;
        isExecuting = true;
        
        try {
            // デフォルト値の設定
            setDefaultValues(newAccounts);
            
            // バリデーションチェック
            validateAccounts(newAccounts);
        } finally {
            isExecuting = false;
        }
    }
    
    // Before Updateイベントの処理
    public void onBeforeUpdate(List<Account> newAccounts, Map<Id, Account> oldAccountMap) {
        if (isExecuting) return;
        isExecuting = true;
        
        try {
            // 更新時の処理
            handleAccountUpdates(newAccounts, oldAccountMap);
        } finally {
            isExecuting = false;
        }
    }
    
    // After Insertイベントの処理
    public void onAfterInsert(List<Account> newAccounts) {
        if (isExecuting) return;
        isExecuting = true;
        
        try {
            // 関連レコードの作成など
            createRelatedRecords(newAccounts);
        } finally {
            isExecuting = false;
        }
    }
    
    // After Updateイベントの処理
    public void onAfterUpdate(List<Account> newAccounts, Map<Id, Account> oldAccountMap) {
        if (isExecuting) return;
        isExecuting = true;
        
        try {
            // 関連レコードの更新など
            updateRelatedRecords(newAccounts, oldAccountMap);
        } finally {
            isExecuting = false;
        }
    }
    
    // 以下、具体的な処理を行うプライベートメソッド
    private void setDefaultValues(List<Account> accounts) {
        for (Account acc : accounts) {
            if (acc.Industry == null) {
                acc.Industry = 'その他';
            }
        }
    }
    
    private void validateAccounts(List<Account> accounts) {
        for (Account acc : accounts) {
            if (String.isBlank(acc.Name)) {
                acc.addError('会社名は必須です');
            }
        }
    }
    
    private void handleAccountUpdates(List<Account> newAccounts, Map<Id, Account> oldAccountMap) {
        for (Account acc : newAccounts) {
            Account oldAcc = oldAccountMap.get(acc.Id);
            
            if (acc.Industry != oldAcc.Industry) {
                acc.Description = '業種が ' + oldAcc.Industry + ' から ' + 
                                 acc.Industry + ' に変更されました。(' + 
                                 System.today() + ')';
            }
        }
    }
    
    private void createRelatedRecords(List<Account> newAccounts) {
        List<Contact> defaultContacts = new List<Contact>();
        
        for (Account acc : newAccounts) {
            Contact defaultContact = new Contact(
                AccountId = acc.Id,
                LastName = acc.Name + ' 担当者',
                Email = 'contact@' + acc.Name.toLowerCase().replaceAll(' ', '') + '.example.com'
            );
            defaultContacts.add(defaultContact);
        }
        
        if (!defaultContacts.isEmpty()) {
            insert defaultContacts;
        }
    }
    
    private void updateRelatedRecords(List<Account> newAccounts, Map<Id, Account> oldAccountMap) {
        // 関連レコードの更新ロジック
    }
}
```

</details>

### 3. テスト駆動開発

Salesforceでは、本番環境へのデプロイに75%以上のコードカバレッジが必要です。また、適切なテストはソリューションの品質を保証します。

```apex
@isTest
private class AccountHelperTest {
    @isTest
    static void testUpdateAccountRating() {
        // テストデータのセットアップ
        Account testAccount = new Account(Name = 'Test Account', Rating = 'Cold');
        insert testAccount;
        
        // テスト実行
        Test.startTest();
        AccountHelper.updateAccountRating(testAccount.Id, 'Hot');
        Test.stopTest();
        
        // 結果の検証
        Account updatedAccount = [SELECT Rating FROM Account WHERE Id = :testAccount.Id];
        System.assertEquals('Hot', updatedAccount.Rating, '取引先の評価が正しく更新されていません');
    }
    
    @isTest
    static void testUpdateAccountRatingWithInvalidId() {
        // 不正なIDでテスト
        Id invalidId = '001000000000000AAA';
        
        // テスト実行
        Test.startTest();
        try {
            AccountHelper.updateAccountRating(invalidId, 'Hot');
            System.assert(false, '例外が発生するべきです');
        } catch (Exception e) {
            // 例外が発生することを期待
            System.assert(true);
        }
        Test.stopTest();
    }
}
```


### 4. パフォーマンスとガバナー制限への対応

Salesforceのガバナー制限を考慮した効率的なコードを書くことが重要です。

**パフォーマンス最適化のポイント**:

1. **クエリの最適化**: 必要なフィールドのみを取得
2. **選択的なSOQLクエリ**: WHERE句で適切にフィルタリング
3. **バルクパターン**: クエリやDML操作をループ外に移動
4. **クエリの結果をキャッシュ**: 同じデータを繰り返し取得しない
5. **非同期処理の活用**: 長時間実行される処理はバッチApexを使用
<details><summary>バッチApexの例</summary>

```apex
global class AccountUpdateBatch implements Database.Batchable<SObject>, Database.Stateful {
    // 処理状況の追跡用
    private Integer recordsProcessed = 0;
    private List<String> errors = new List<String>();
    
    // バッチの開始メソッド
    global Database.QueryLocator start(Database.BatchableContext BC) {
        // 処理対象のレコードを取得するSOQLを返す
        return Database.getQueryLocator(
            'SELECT Id, Name, Industry, LastModifiedDate ' +
            'FROM Account ' +
            'WHERE LastModifiedDate < LAST_N_DAYS:90'
        );
    }
    
    // バッチの実行メソッド
    global void execute(Database.BatchableContext BC, List<Account> scope) {
        // 更新するアカウントのリスト
        List<Account> accountsToUpdate = new List<Account>();
        
        for (Account acc : scope) {
            try {
                // アカウントの更新処理
                acc.Description = '一括処理による更新: ' + System.today();
                accountsToUpdate.add(acc);
                recordsProcessed++;
            } catch (Exception e) {
                // エラー情報を記録
                errors.add('Account Id: ' + acc.Id + ', Error: ' + e.getMessage());
            }
        }
        
        // 部分的な成功を許可するupdateオプション
        Database.SaveResult[] results = Database.update(accountsToUpdate, false);
        
        // 結果を処理
        for (Integer i = 0; i < results.size(); i++) {
            if (!results[i].isSuccess()) {
                Database.Error[] errs = results[i].getErrors();
                for (Database.Error err : errs) {
                    errors.add('Account Id: ' + accountsToUpdate[i].Id + ', Error: ' + err.getMessage());
                }
                recordsProcessed--; // 失敗したレコードをカウントから除外
            }
        }
    }
    
    // バッチの完了メソッド
    global void finish(Database.BatchableContext BC) {
        // 管理者にメール通知を送信
        Messaging.SingleEmailMessage mail = new Messaging.SingleEmailMessage();
        String[] toAddresses = new String[] {'admin@example.com'};
        mail.setToAddresses(toAddresses);
        mail.setSubject('アカウント更新バッチ処理完了');
        
        String body = recordsProcessed + ' 件のアカウントが更新されました。\n\n';
        
        if (!errors.isEmpty()) {
            body += '以下のエラーが発生しました：\n';
            for (String error : errors) {
                body += error + '\n';
            }
        }
        
        mail.setPlainTextBody(body);
        Messaging.sendEmail(new Messaging.SingleEmailMessage[] { mail });
        
        // 次のバッチをスケジュール（必要に応じて）
        // 例：毎月1日に実行するようスケジュール
        if (System.today().day() == 1) {
            AccountUpdateBatch nextBatch = new AccountUpdateBatch();
            System.scheduleBatch(nextBatch, 'Monthly Account Update', 1, 2000);
        }
    }
}

// バッチの実行
public static void executeAccountUpdateBatch() {
    AccountUpdateBatch batch = new AccountUpdateBatch();
    Database.executeBatch(batch, 200); // バッチサイズを200に設定
}
```

</details>

## 📝 実務で役立つデザインパターン

Salesforceでの開発において、よく使われるデザインパターンをいくつか紹介します。これらのパターンを理解することで、拡張性と保守性の高いコードを書くことができます。

### 1. シングルトンパターン

グローバルに使用したい設定や、一度だけインスタンス化したいクラスに利用します。

```apex
public class ConfigurationManager {
    // プライベート静的変数にシングルトンインスタンスを保持
    private static ConfigurationManager instance;
    
    // 設定値を保持するマップ
    private Map<String, Object> configValues;
    
    // プライベートコンストラクタ
    private ConfigurationManager() {
        // 設定を初期化
        this.configValues = new Map<String, Object>();
        loadConfigValues();
    }
    
    // インスタンスを取得するメソッド
    public static ConfigurationManager getInstance() {
        if (instance == null) {
            instance = new ConfigurationManager();
        }
        return instance;
    }
    
    // 設定値を読み込むメソッド
    private void loadConfigValues() {
        // カスタム設定から値を読み込む例
        for (Application_Setting__c setting : [SELECT Name, Value__c FROM Application_Setting__c]) {
            configValues.put(setting.Name, setting.Value__c);
        }
    }
    
    // 設定値を取得するメソッド
    public Object getConfigValue(String key) {
        return configValues.get(key);
    }
    
    // 設定値を設定するメソッド
    public void setConfigValue(String key, Object value) {
        configValues.put(key, value);
    }
}

// 使用例
ConfigurationManager config = ConfigurationManager.getInstance();
String apiEndpoint = (String)config.getConfigValue('API_ENDPOINT');
```


### 2. ファクトリーパターン

オブジェクトの生成を一つのクラスに委託することで、コードの重複を避け、拡張性を高めます。

```apex
// レコードファクトリーインターフェース
public interface IRecordFactory {
    SObject createRecord();
}

// 取引先ファクトリー
public class AccountFactory implements IRecordFactory {
    public SObject createRecord() {
        return new Account(
            Name = 'デフォルト会社名',
            Industry = 'テクノロジー',
            BillingCountry = '日本'
        );
    }
}

// 取引先責任者ファクトリー
public class ContactFactory implements IRecordFactory {
    public SObject createRecord() {
        return new Contact(
            LastName = 'テスト 太郎',
            Email = 'test@example.com'
        );
    }
}

// ファクトリークラス
public class RecordFactory {
    private static Map<String, Type> factoryTypes = new Map<String, Type>{
        'Account' => AccountFactory.class,
        'Contact' => ContactFactory.class
        // 他のオブジェクトタイプも追加可能
    };
    
    public static SObject createRecord(String objectType) {
        if (!factoryTypes.containsKey(objectType)) {
            throw new RecordFactoryException('未サポートのオブジェクトタイプです: ' + objectType);
        }
        
        Type factoryType = factoryTypes.get(objectType);
        IRecordFactory factory = (IRecordFactory)factoryType.newInstance();
        return factory.createRecord();
    }
    
    public class RecordFactoryException extends Exception {}
}

// 使用例
Account acc = (Account)RecordFactory.createRecord('Account');
Contact con = (Contact)RecordFactory.createRecord('Contact');
```


## 🏆 認定試験合格のためのロードマップ

Salesforce Platform Developer I認定試験の合格を目指すなら、以下のロードマップを参考にしてください：

```mermaid
graph TD
    A[学習計画の立案] --> B[基礎知識の習得]
    B --> C[実践演習]
    C --> D[模擬試験の受験]
    D --> E[弱点分野の強化]
    E --> F[実際の試験]
    
    B --> G[Trailheadの活用]
    B --> H[公式ドキュメントの参照]
    B --> I[コミュニティの活用]
    
    C --> J[開発者環境でのプラクティス]
    C --> K[サンプルプロジェクトの構築]
    
    D --> L[公式模擬試験]
    D --> M[サードパーティの練習問題]
```


### Step 1: Trailheadで基礎を学ぶ

Salesforceの公式学習プラットフォームであるTrailheadを活用しましょう。

- **「Developer Beginner」**トレイル: プログラミングの基礎がない方向け
- **「Apex Basics \& Database」**モジュール: Apexとデータベースの基本を学ぶ
- **「Platform Developer I Certification Prep」**トレイルミックス: 試験対策に特化したコース


### Step 2: 実践的な開発経験を積む

1. **Developer Edition組織**：無料の開発環境でApexやSOQLの練習
2. **Trailhead Playground**：Trailheadの課題を進めながら実装スキルを磨く
3. **サンプルアプリケーションの構築**：実際に機能を実装して経験を積む

### Step 3: 模擬試験と復習

1. **公式模擬試験**：Salesforceが提供する模擬試験（有料）
2. **Focus on Force**：非公式ながら高品質な模擬試験と学習教材
3. **弱点分野の重点的な復習**：間違えた問題や苦手な分野を特に集中して復習

:::note warn
試験対策には最低でも2～3ヶ月の準備期間を確保することをお勧めします。特に実務経験が少ない方は、実際にコードを書く時間を多く取りましょう。
:::

## 📈 資格取得後のキャリアパス

Salesforce Platform Developer I資格を取得すると、様々なキャリアオプションが広がります：

1. **Salesforceデベロッパー**：Salesforceプラットフォーム上でのカスタム開発を担当
2. **テクニカルコンサルタント**：クライアントのビジネス要件を技術的に実現
3. **アーキテクト**：より複雑なSalesforceソリューションの設計（上位資格の取得で）
4. **テクニカルリード**：開発チームをリードし、技術的な意思決定を行う

さらに上を目指すための次のステップとしては：

- **Platform Developer II (PDII)** 資格の取得
- **Platform App Builder** 資格の取得
- **JavaScript Developer I** 資格の取得
- 最終的に **Salesforce Certified Technical Architect** を目指す


## 🔍 実践的なアプリケーション開発例

最後に、実務でよく求められるケーススタディを通して、Apexとオブジェクト開発の実践例を見てみましょう。

### ケーススタディ：営業活動管理アプリケーション

取引先と商談に関連する営業活動を追跡・分析するアプリケーションを開発する例です。

#### 1. カスタムオブジェクトの定義

```xml
<!-- Sales_Activity__c カスタムオブジェクト -->
<CustomObject xmlns="http://soap.sforce.com/2006/04/metadata">
    <label>営業活動</label>
    <pluralLabel>営業活動</pluralLabel>
    <nameField>
        <type>AutoNumber</type>
        <label>活動番号</label>
        <displayFormat>ACT-{0000}</displayFormat>
    </nameField>
    <deploymentStatus>Deployed</deploymentStatus>
    <sharingModel>ReadWrite</sharingModel>
    <enableReports>true</enableReports>
    <enableSearch>true</enableSearch>
</CustomObject>

<!-- カスタムフィールド：活動日 -->
<CustomField xmlns="http://soap.sforce.com/2006/04/metadata">
    <fullName>Sales_Activity__c.Activity_Date__c</fullName>
    <label>活動日</label>
    <required>true</required>
    <type>Date</type>
</CustomField>

<!-- カスタムフィールド：活動種別 -->
<CustomField xmlns="http://soap.sforce.com/2006/04/metadata">
    <fullName>Sales_Activity__c.Activity_Type__c</fullName>
    <label>活動種別</label>
    <required>true</required>
    <type>Picklist</type>
    <valueSet>
        <restricted>true</restricted>
        <valueSetDefinition>
            <sorted>false</sorted>
            <value>
                <fullName>電話</fullName>
                <default>false</default>
                <label>電話</label>
            </value>
            <value>
                <fullName>メール</fullName>
                <default>false</default>
                <label>メール</label>
            </value>
            <value>
                <fullName>訪問</fullName>
                <default>false</default>
                <label>訪問</label>
            </value>
            <value>
                <fullName>Web会議</fullName>
                <default>false</default>
                <label>Web会議</label>
            </value>
        </valueSetDefinition>
    </valueSet>
</CustomField>

<!-- カスタムフィールド：商談（参照関係） -->
<CustomField xmlns="http://soap.sforce.com/2006/04/metadata">
    <fullName>Sales_Activity__c.Opportunity__c</fullName>
    <label>商談</label>
    <referenceTo>Opportunity</referenceTo>
    <relationshipLabel>営業活動</relationshipLabel>
    <relationshipName>Sales_Activities</relationshipName>
    <type>Lookup</type>
</CustomField>

<!-- カスタムフィールド：取引先（参照関係） -->
<CustomField xmlns="http://soap.sforce.com/2006/04/metadata">
    <fullName>Sales_Activity__c.Account__c</fullName>
    <label>取引先</label>
    <referenceTo>Account</referenceTo>
    <relationshipLabel>営業活動</relationshipLabel>
    <relationshipName>Sales_Activities</relationshipName>
    <type>Lookup</type>
</CustomField>
```


#### 2. 営業活動サービスクラスの実装

<details><summary>営業活動を管理するサービスクラス</summary>

```apex
public with sharing class SalesActivityService {
    
    /**
     * 新しい営業活動を作成する
     * @param accountId 取引先ID
     * @param opportunityId 商談ID（省略可能）
     * @param activityType 活動種別
     * @param activityDate 活動日
     * @param description 説明
     * @return 作成された営業活動レコード
     */
    public static Sales_Activity__c createActivity(
        Id accountId, 
        Id opportunityId, 
        String activityType, 
        Date activityDate, 
        String description
    ) {
        // パラメータの検証
        if (accountId == null) {
            throw new SalesActivityException('取引先IDは必須です');
        }
        
        if (activityType == null || activityDate == null) {
            throw new SalesActivityException('活動種別と活動日は必須です');
        }
        
        // 営業活動レコードの作成
        Sales_Activity__c activity = new Sales_Activity__c(
            Account__c = accountId,
            Opportunity__c = opportunityId,
            Activity_Type__c = activityType,
            Activity_Date__c = activityDate,
            Description__c = description
        );
        
        try {
            insert activity;
            return activity;
        } catch (Exception e) {
            throw new SalesActivityException('営業活動の作成に失敗しました: ' + e.getMessage());
        }
    }
    
    /**
     * 取引先に関連する営業活動の概要を取得する
     * @param accountId 取引先ID
     * @return 活動種別ごとの件数を含む概要情報
     */
    public static Map<String, Object> getActivitySummary(Id accountId) {
        if (accountId == null) {
            throw new SalesActivityException('取引先IDは必須です');
        }
        
        // 活動種別ごとの集計結果を取得
        AggregateResult[] results = [
            SELECT Activity_Type__c, COUNT(Id) activityCount
            FROM Sales_Activity__c
            WHERE Account__c = :accountId
            GROUP BY Activity_Type__c
        ];
        
        // 営業活動の合計
        Integer totalActivities = [
            SELECT COUNT() FROM Sales_Activity__c WHERE Account__c = :accountId
        ];
        
        // 最新の活動日
        Date latestActivityDate = null;
        List<Sales_Activity__c> latestActivities = [
            SELECT Activity_Date__c
            FROM Sales_Activity__c
            WHERE Account__c = :accountId
            ORDER BY Activity_Date__c DESC
            LIMIT 1
        ];
        
        if (!latestActivities.isEmpty()) {
            latestActivityDate = latestActivities[0].Activity_Date__c;
        }
        
        // 結果をマップにまとめる
        Map<String, Object> summary = new Map<String, Object>();
        summary.put('totalActivities', totalActivities);
        summary.put('latestActivityDate', latestActivityDate);
        
        // 活動種別ごとの集計
        Map<String, Integer> activityCounts = new Map<String, Integer>();
        for (AggregateResult result : results) {
            String activityType = (String)result.get('Activity_Type__c');
            Integer count = (Integer)result.get('activityCount');
            activityCounts.put(activityType, count);
        }
        summary.put('activityCounts', activityCounts);
        
        return summary;
    }
    
    /**
     * 商談に関連する営業活動を取得する
     * @param opportunityId 商談ID
     * @return 営業活動のリスト
     */
    public static List<Sales_Activity__c> getActivitiesByOpportunity(Id opportunityId) {
        if (opportunityId == null) {
            throw new SalesActivityException('商談IDは必須です');
        }
        
        return [
            SELECT Id, Activity_Type__c, Activity_Date__c, Description__c,
                   CreatedBy.Name, LastModifiedDate
            FROM Sales_Activity__c
            WHERE Opportunity__c = :opportunityId
            ORDER BY Activity_Date__c DESC
        ];
    }
    
    /**
     * 期間指定での活動レポートを生成する
     * @param startDate 開始日
     * @param endDate 終了日
     * @param activityTypes 対象の活動種別（指定がない場合は全種別）
     * @return 期間内の活動レポート
     */
    public static List<ActivityReport> generateActivityReport(
        Date startDate, 
        Date endDate, 
        List<String> activityTypes
    ) {
        // 検索条件の構築
        String query = 'SELECT Account__r.Name, Activity_Type__c, Activity_Date__c, ' +
                      'Opportunity__r.Name, CreatedBy.Name ' +
                      'FROM Sales_Activity__c ' +
                      'WHERE Activity_Date__c >= :startDate AND Activity_Date__c <= :endDate ';
        
        // 活動種別でフィルタリング（指定がある場合）
        if (activityTypes != null && !activityTypes.isEmpty()) {
            query += 'AND Activity_Type__c IN :activityTypes ';
        }
        
        query += 'ORDER BY Activity_Date__c DESC';
        
        // クエリの実行
        List<Sales_Activity__c> activities = Database.query(query);
        
        // レポートデータの生成
        List<ActivityReport> reports = new List<ActivityReport>();
        for (Sales_Activity__c activity : activities) {
            ActivityReport report = new ActivityReport();
            report.activityDate = activity.Activity_Date__c;
            report.activityType = activity.Activity_Type__c;
            report.accountName = activity.Account__r.Name;
            report.opportunityName = activity.Opportunity__r != null ? 
                                    activity.Opportunity__r.Name : null;
            report.createdByName = activity.CreatedBy.Name;
            
            reports.add(report);
        }
        
        return reports;
    }
    
    // レポート返却用の内部クラス
    public class ActivityReport {
        public Date activityDate { get; set; }
        public String activityType { get; set; }
        public String accountName { get; set; }
        public String opportunityName { get; set; }
        public String createdByName { get; set; }
    }
    
    // カスタム例外クラス
    public class SalesActivityException extends Exception {}
}
```

</details>

#### 3. スケジュールされた月次レポート生成

<details><summary>定期的な活動レポート生成のスケジュールクラス</summary>

```apex
global class MonthlyActivityReportScheduler implements Schedulable {
    
    global void execute(SchedulableContext sc) {
        // 先月の日付範囲を計算
        Date today = Date.today();
        Date firstDayLastMonth = today.toStartOfMonth().addMonths(-1);
        Date lastDayLastMonth = today.toStartOfMonth().addDays(-1);
        
        // 月次レポートを生成するバッチを実行
        Database.executeBatch(new MonthlyActivityReportBatch(firstDayLastMonth, lastDayLastMonth));
    }
    
    // スケジュールジョブを設定するメソッド
    public static String scheduleMonthlyJob() {
        // 毎月1日の午前3時に実行するようスケジュール
        String cronExp = '0 0 3 1 * ?';
        MonthlyActivityReportScheduler scheduler = new MonthlyActivityReportScheduler();
        return System.schedule('月次営業活動レポート', cronExp, scheduler);
    }
}

// 月次活動レポートを生成するバッチクラス
global class MonthlyActivityReportBatch implements Database.Batchable<SObject>, Database.Stateful {
    private Date startDate;
    private Date endDate;
    private List<AccountActivitySummary> accountSummaries;
    
    // コンストラクタ
    public MonthlyActivityReportBatch(Date startDate, Date endDate) {
        this.startDate = startDate;
        this.endDate = endDate;
        this.accountSummaries = new List<AccountActivitySummary>();
    }
    
    // バッチの開始メソッド
    global Database.QueryLocator start(Database.BatchableContext BC) {
        // 対象期間に活動がある取引先を取得
        return Database.getQueryLocator(
            'SELECT Id, Name ' +
            'FROM Account ' +
            'WHERE Id IN (SELECT Account__c FROM Sales_Activity__c ' +
            '             WHERE Activity_Date__c >= :startDate AND Activity_Date__c <= :endDate) ' +
            'ORDER BY Name'
        );
    }
    
    // バッチの実行メソッド
    global void execute(Database.BatchableContext BC, List<Account> scope) {
        for (Account acc : scope) {
            // 活動種別ごとの集計を取得
            AggregateResult[] results = [
                SELECT Activity_Type__c, COUNT(Id) activityCount
                FROM Sales_Activity__c
                WHERE Account__c = :acc.Id
                AND Activity_Date__c >= :startDate AND Activity_Date__c <= :endDate
                GROUP BY Activity_Type__c
            ];
            
            // 総活動数を取得
            Integer totalActivities = [
                SELECT COUNT() 
                FROM Sales_Activity__c 
                WHERE Account__c = :acc.Id
                AND Activity_Date__c >= :startDate AND Activity_Date__c <= :endDate
            ];
            
            // 結果を格納
            AccountActivitySummary summary = new AccountActivitySummary();
            summary.accountId = acc.Id;
            summary.accountName = acc.Name;
            summary.totalActivities = totalActivities;
            summary.activityCounts = new Map<String, Integer>();
            
            for (AggregateResult result : results) {
                String activityType = (String)result.get('Activity_Type__c');
                Integer count = (Integer)result.get('activityCount');
                summary.activityCounts.put(activityType, count);
            }
            
            accountSummaries.add(summary);
        }
    }
    
    // バッチの完了メソッド
    global void finish(Database.BatchableContext BC) {
        // CSVレポートを生成
        String reportContent = generateCSVReport();
        
        // メールに添付してマネージャーに送信
        Messaging.SingleEmailMessage email = new Messaging.SingleEmailMessage();
        
        // メール送信先の取得（カスタム設定から取得することも可能）
        List<String> recipients = getSalesManagerEmails();
        email.setToAddresses(recipients);
        
        // メールの件名と本文
        String monthName = startDate.format().split(' ')[0]; // 月の名前を取得
        email.setSubject('月次営業活動レポート: ' + monthName);
        email.setPlainTextBody(
            monthName + ' の営業活動レポートが添付されています。\n\n' +
            '期間: ' + startDate.format() + ' 〜 ' + endDate.format() + '\n' +
            '対象取引先数: ' + accountSummaries.size() + '\n\n' +
            'ご質問があれば担当者までお問い合わせください。'
        );
        
        // レポートをCSVファイルとして添付
        Messaging.EmailFileAttachment attachment = new Messaging.EmailFileAttachment();
        attachment.setFileName('営業活動レポート_' + monthName + '.csv');
        attachment.setBody(Blob.valueOf(reportContent));
        attachment.setContentType('text/csv');
        email.setFileAttachments(new Messaging.EmailFileAttachment[] { attachment });
        
        // メール送信
        try {
            Messaging.sendEmail(new Messaging.SingleEmailMessage[] { email });
            System.debug('月次活動レポートのメール送信に成功しました');
        } catch (Exception e) {
            System.debug('メール送信中にエラーが発生しました: ' + e.getMessage());
        }
    }
    
    // CSVレポートを生成するプライベートメソッド
    private String generateCSVReport() {
        String header = '取引先名,総活動数,電話,メール,訪問,Web会議\n';
        String reportBody = '';
        
        for (AccountActivitySummary summary : accountSummaries) {
            String line = '"' + summary.accountName + '",' + 
                         summary.totalActivities + ',' +
                         (summary.activityCounts.get('電話') != null ? summary.activityCounts.get('電話') : 0) + ',' +
                         (summary.activityCounts.get('メール') != null ? summary.activityCounts.get('メール') : 0) + ',' +
                         (summary.activityCounts.get('訪問') != null ? summary.activityCounts.get('訪問') : 0) + ',' +
                         (summary.activityCounts.get('Web会議') != null ? summary.activityCounts.get('Web会議') : 0);
            
            reportBody += line + '\n';
        }
        
        return header + reportBody;
    }
    
    // 営業マネージャーのメールアドレスを取得するプライベートメソッド
    private List<String> getSalesManagerEmails() {
        List<String> emails = new List<String>();
        
        // 営業マネージャーのユーザーを取得
        for (User manager : [
            SELECT Email 
            FROM User 
            WHERE Profile.Name = '営業マネージャー' 
            AND IsActive = true
        ]) {
            emails.add(manager.Email);
        }
        
        // バックアップとして管理者も追加
        emails.add('admin@example.com');
        
        return emails;
    }
    
    // 取引先ごとの活動サマリーを保持する内部クラス
    private class AccountActivitySummary {
        public Id accountId;
        public String accountName;
        public Integer totalActivities;
        public Map<String, Integer> activityCounts;
    }
}
```

</details>

## 📝 まとめ

Salesforce Platform Developer I (PDI)資格は、Salesforceプラットフォームでのカスタム開発能力を証明する重要な資格です。この記事では、Apex、SOQL、基本開発原則について詳しく解説しました。

**主なポイント**:

1. **Apexプログラミング**：JavaやC\#に似た構文を持ち、オブジェクト指向のプログラミング言語
2. **SOQL**：Salesforceのデータを効率的に検索するためのクエリ言語
3. **基本開発原則**：バルク処理、ガバナー制限への対応、トリガーのベストプラクティス
4. **実践的な開発**：デザインパターンの活用と実務で役立つコード事例

Salesforce開発者として成功するためには、単に文法を覚えるだけでなく、プラットフォームの特性やベストプラクティスを理解することが重要です。継続的な学習と実践を通じて、スキルを磨いていきましょう。

Salesforce Platform Developer I資格の取得は、キャリアの新たな可能性を広げるステップとなるでしょう。この記事が皆さんの学習の一助となれば幸いです。

# 最後に：業務委託のご相談を承ります

私は業務委託エンジニアとしてWEB制作やシステム開発を請け負っています。最新技術を活用したレスポンシブなWebサイト制作、インタラクティブなアプリケーション開発、API連携など幅広いご要望に対応可能です。

「課題解決に向けた即戦力が欲しい」「高品質なWeb制作を依頼したい」という方は、お気軽にご相談ください。一緒にビジネスの成長を目指しましょう！

👉 [ポートフォリオ](https://yushiyamamoto.vercel.app/)

🌳 [らくらくサイト](https://rakuraku-site.prodouga.com/)